From 7fe0cc4d450c753bc237eaf6a1ffccc978cf222e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 19 Jan 2026 09:55:45 +0100 Subject: [PATCH 01/20] Add `MediaSource.withCleanUrl` method that removes invalid fragment data from MXC urls We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid, but the URL before that fragment part is valid and can be displayed --- .../libraries/matrix/api/media/MediaSource.kt | 23 +++++++++++++++++++ .../matrix/impl/media/RustMediaLoader.kt | 3 ++- .../matrix/ui/media/CoilMediaFetcher.kt | 17 +++++++++----- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt index 56e32ba2fc..9d0ea7fd13 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.api.media import android.os.Parcelable +import androidx.core.net.toUri import kotlinx.parcelize.Parcelize @Parcelize @@ -22,3 +23,25 @@ data class MediaSource( */ val json: String? = null, ) : Parcelable + +/** + * Returns a new [MediaSource] with a valid URL. + */ +fun MediaSource.withCleanUrl(): MediaSource { + val uri = this.url.toUri() + if (uri.scheme != "mxc") return this + + // We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid + val cleanedUrl = buildString { + append(uri.scheme) + if (!this.endsWith("://")) { + append("://") + } + append(uri.host) + if (uri.path != null) { + append(uri.path) + } + } + + return this.copy(url = cleanedUrl) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 892c99f64a..653b4d055d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.withCleanUrl import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.use @@ -91,7 +92,7 @@ class RustMediaLoader( return if (json != null) { RustMediaSource.fromJson(json) } else { - RustMediaSource.fromUrl(url) + RustMediaSource.fromUrl(withCleanUrl().url) } } } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index 06321f64a9..e08746f8df 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -16,6 +16,7 @@ import coil3.fetch.SourceFetchResult import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile +import io.element.android.libraries.matrix.api.media.withCleanUrl import okio.Buffer import okio.FileSystem import okio.Path.Companion.toOkioPath @@ -28,14 +29,18 @@ internal class CoilMediaFetcher( ) : Fetcher { override suspend fun fetch(): FetchResult? { val source = mediaData.source - if (source == null) { - Timber.e("MediaData source is null") - return null + val mediaSource = when { + source == null -> { + Timber.e("MediaData source is null") + return null + } + source.url.startsWith("mxc:") -> source.withCleanUrl() + else -> source } return when (val kind = mediaData.kind) { - is MediaRequestData.Kind.Content -> fetchContent(source) - is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(source, kind) - is MediaRequestData.Kind.File -> fetchFile(source, kind) + is MediaRequestData.Kind.Content -> fetchContent(mediaSource) + is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind) + is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind) } } From cdd850d4dd5606587c948e76fa3c964993997b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 27 Feb 2026 09:33:03 +0100 Subject: [PATCH 02/20] Apply suggestion: - Added `MediaSource.safeUrl` property replacing `withCleanUrl` method. - Made `url` private so it can't be used externally. - Reverted code in `CoilMediaFetcher` - Also add tests --- .../model/event/TimelineItemStickerContent.kt | 2 +- .../libraries/matrix/api/media/MediaSource.kt | 35 +++++++------------ .../matrix/api/media/MediaSourceTest.kt | 26 ++++++++++++++ .../matrix/impl/media/RustMediaLoader.kt | 3 +- .../matrix/ui/media/CoilMediaFetcher.kt | 13 +++---- .../matrix/ui/media/MediaRequestDataKeyer.kt | 2 +- .../impl/viewer/MediaViewerDataSource.kt | 6 ++-- .../notifications/NotificationMediaRepo.kt | 2 +- .../voiceplayer/impl/VoiceMessageMediaRepo.kt | 2 +- 9 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/media/MediaSourceTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt index d333c0b6e7..9e2f08d5c7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt @@ -30,5 +30,5 @@ data class TimelineItemStickerContent( /* Stickers are supposed to be small images so we allow using the mediaSource (unless the url is empty) */ - val preferredMediaSource = if (mediaSource.url.isEmpty()) thumbnailSource else mediaSource + val preferredMediaSource = if (mediaSource.safeUrl.isEmpty()) thumbnailSource else mediaSource } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt index 9d0ea7fd13..2f7fff84ef 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt @@ -9,7 +9,7 @@ package io.element.android.libraries.matrix.api.media import android.os.Parcelable -import androidx.core.net.toUri +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -17,31 +17,20 @@ data class MediaSource( /** * Url of the media. */ - val url: String, + private val url: String, /** * This is used to hold data for encrypted media. */ val json: String? = null, -) : Parcelable - -/** - * Returns a new [MediaSource] with a valid URL. - */ -fun MediaSource.withCleanUrl(): MediaSource { - val uri = this.url.toUri() - if (uri.scheme != "mxc") return this - - // We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid - val cleanedUrl = buildString { - append(uri.scheme) - if (!this.endsWith("://")) { - append("://") - } - append(uri.host) - if (uri.path != null) { - append(uri.path) - } +) : Parcelable { + /** + * A URL with invalid parts (like `#fragment`, if it's an MXC url) removed. + */ + @IgnoredOnParcel + val safeUrl = if (url.startsWith("mxc")) { + // We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid + url.substringBefore("#") + } else { + url } - - return this.copy(url = cleanedUrl) } diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/media/MediaSourceTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/media/MediaSourceTest.kt new file mode 100644 index 0000000000..55c1381a4d --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/media/MediaSourceTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.media.aMediaSource +import org.junit.Test + +class MediaSourceTest { + @Test + fun `safeUrl removes the fragment part in MXC urls`() { + val mediaSource = aMediaSource(url = "mxc://matrix.org/url#fragment") + assertThat(mediaSource.safeUrl).isEqualTo("mxc://matrix.org/url") + } + + @Test + fun `safeUrl keeps the fragment part in a non-MXC url`() { + val mediaSource = aMediaSource(url = "https://matrix.org/url#fragment") + assertThat(mediaSource.safeUrl).isEqualTo("https://matrix.org/url#fragment") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 653b4d055d..81946980e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.api.media.withCleanUrl import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.use @@ -92,7 +91,7 @@ class RustMediaLoader( return if (json != null) { RustMediaSource.fromJson(json) } else { - RustMediaSource.fromUrl(withCleanUrl().url) + RustMediaSource.fromUrl(safeUrl) } } } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index e08746f8df..39f67b41d7 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -16,7 +16,6 @@ import coil3.fetch.SourceFetchResult import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile -import io.element.android.libraries.matrix.api.media.withCleanUrl import okio.Buffer import okio.FileSystem import okio.Path.Companion.toOkioPath @@ -28,14 +27,10 @@ internal class CoilMediaFetcher( private val mediaData: MediaRequestData, ) : Fetcher { override suspend fun fetch(): FetchResult? { - val source = mediaData.source - val mediaSource = when { - source == null -> { - Timber.e("MediaData source is null") - return null - } - source.url.startsWith("mxc:") -> source.withCleanUrl() - else -> source + val mediaSource = mediaData.source + if (mediaSource == null) { + Timber.e("MediaData source is null") + return null } return when (val kind = mediaData.kind) { is MediaRequestData.Kind.Content -> fetchContent(mediaSource) diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt index 488803e933..660b80500a 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt @@ -25,5 +25,5 @@ internal class MediaRequestDataKeyer : Keyer { } private fun MediaRequestData.toKey(): String? { - return source?.let { "${it.url}_$kind" } + return source?.let { "${it.safeUrl}_$kind" } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index ae7b54ccdd..928e5d9ca8 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -126,7 +126,7 @@ class MediaViewerDataSource( when (mediaItem) { is MediaItem.DateSeparator -> Unit is MediaItem.Event -> { - val sourceUrl = mediaItem.mediaSource().url + val sourceUrl = mediaItem.mediaSource().safeUrl val localMedia = localMediaStates.getOrPut(sourceUrl) { mutableStateOf(AsyncData.Uninitialized) } @@ -153,7 +153,7 @@ class MediaViewerDataSource( }.toImmutableList() fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { - localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized + localMediaStates[data.mediaSource.safeUrl]?.value = AsyncData.Uninitialized } suspend fun loadMore(direction: Timeline.PaginationDirection) { @@ -162,7 +162,7 @@ class MediaViewerDataSource( suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) { Timber.d("loadMedia for ${data.eventId}") - val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) { + val localMediaState = localMediaStates.getOrPut(data.mediaSource.safeUrl) { mutableStateOf(AsyncData.Uninitialized) } localMediaState.value = AsyncData.Loading() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt index f309f754c3..39b3f3fda2 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt @@ -101,7 +101,7 @@ class DefaultNotificationMediaRepo( } } - private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(url)?.let { + private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(safeUrl)?.let { File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it") } } diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt index 7cc09c4009..2d1c9f7277 100644 --- a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt @@ -95,7 +95,7 @@ class DefaultVoiceMessageMediaRepo( } } - private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let { + private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.safeUrl)?.let { File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it") } } From b3b22033aafdc3def88a050a16d4a37fead4085b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 24 Feb 2026 12:38:20 +0100 Subject: [PATCH 03/20] Handle EventRedacted case. Fixes #5569 --- .../api/exception/NotificationResolverException.kt | 5 +++++ .../impl/notification/RustNotificationService.kt | 4 ++++ .../libraries/push/impl/push/DefaultPushHandler.kt | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt index fd3adf2592..a83fbd1e23 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt @@ -22,6 +22,11 @@ sealed class NotificationResolverException : Exception() { */ data object EventFilteredOut : NotificationResolverException() + /** + * The event was found but it has been redacted. + */ + data object EventRedacted : NotificationResolverException() + /** * An unexpected error occurred while trying to resolve the event. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt index 59d95af05b..238a8d988c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -66,6 +66,10 @@ class RustNotificationService( Timber.d("Could not retrieve event for notification with $eventId - event filtered out") put(eventId, Result.failure(NotificationResolverException.EventFilteredOut)) } + NotificationStatus.EventRedacted -> { + Timber.d("Could not retrieve event for notification with $eventId - event redacted") + put(eventId, Result.failure(NotificationResolverException.EventRedacted)) + } } } is BatchNotificationResult.Error -> { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 3f13f33818..0053a18838 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -124,6 +124,14 @@ class DefaultPushHandler( sessionId = request.sessionId, comment = "Push handled successfully but notification was filtered out", ) + } else if (exception is NotificationResolverException.EventRedacted) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + comment = "Push handled successfully but event has been redacted", + ) } else { val reason = when (exception) { is NotificationResolverException.EventNotFound -> "Event not found" @@ -155,6 +163,10 @@ class DefaultPushHandler( // Do nothing, we don't want to show a notification for filtered out events null } + is NotificationResolverException.EventRedacted -> { + // Do nothing, we don't want to show a notification for redacted events + null + } else -> { Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event") ResolvedPushEvent.Event( From 47d132ef1bb5f8a5efdb9b9c3e4c688eadf2122e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:47:41 +0000 Subject: [PATCH 04/20] Update dependency org.matrix.rustcomponents:sdk-android to v26.03.1 --- 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 8a3c055ad9..08fdf33946 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -178,7 +178,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.03.0" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.03.1" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } From 286aa5614514300a9e0e05e701071ae123720a9a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 09:18:43 +0100 Subject: [PATCH 05/20] Fix API break. --- .../matrix/impl/timeline/RoomTimelineExtensions.kt | 6 +++--- .../android/libraries/matrix/impl/timeline/RustTimeline.kt | 6 +++--- .../libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt | 4 ++-- .../libraries/matrix/impl/timeline/RustTimelineTest.kt | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt index bc0e5eed99..c932230217 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -21,11 +21,11 @@ import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineInterface import org.matrix.rustcomponents.sdk.TimelineListener import timber.log.Timber -import uniffi.matrix_sdk.RoomPaginationStatus +import uniffi.matrix_sdk.PaginationStatus -internal fun TimelineInterface.liveBackPaginationStatus(): Flow = callbackFlow { +internal fun TimelineInterface.liveBackPaginationStatus(): Flow = callbackFlow { val listener = object : PaginationStatusListener { - override fun onUpdate(status: RoomPaginationStatus) { + override fun onUpdate(status: PaginationStatus) { trySend(status) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 391349b52d..0ee7239933 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -71,7 +71,7 @@ import org.matrix.rustcomponents.sdk.UploadParameters import org.matrix.rustcomponents.sdk.UploadSource import org.matrix.rustcomponents.sdk.use import timber.log.Timber -import uniffi.matrix_sdk.RoomPaginationStatus +import uniffi.matrix_sdk.PaginationStatus import java.io.File import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline @@ -147,8 +147,8 @@ class RustTimeline( .onEach { backPaginationStatus -> updatePaginationStatus(Timeline.PaginationDirection.BACKWARDS) { when (backPaginationStatus) { - is RoomPaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitTimelineStart) - is RoomPaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true) + is PaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitTimelineStart) + is PaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true) } } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt index d45330bd3f..1c65ab00f2 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt @@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.Timeline import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineListener -import uniffi.matrix_sdk.RoomPaginationStatus +import uniffi.matrix_sdk.PaginationStatus class FakeFfiTimeline : Timeline(NoHandle) { private var listener: TimelineListener? = null @@ -33,7 +33,7 @@ class FakeFfiTimeline : Timeline(NoHandle) { return FakeFfiTaskHandle() } - fun emitPaginationStatus(status: RoomPaginationStatus) { + fun emitPaginationStatus(status: PaginationStatus) { paginationStatusListener!!.onUpdate(status) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt index ab46e8aa5f..4c96800cd0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.TimelineDiff -import uniffi.matrix_sdk.RoomPaginationStatus +import uniffi.matrix_sdk.PaginationStatus import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline class RustTimelineTest { @@ -68,10 +68,10 @@ class RustTimelineTest { // Start pagination sut.paginate(Timeline.PaginationDirection.BACKWARDS) // Simulate SDK starting pagination - inner.emitPaginationStatus(RoomPaginationStatus.Paginating) + inner.emitPaginationStatus(PaginationStatus.Paginating) // No new events received // Simulate SDK stopping pagination, more event to load - inner.emitPaginationStatus(RoomPaginationStatus.Idle(hitTimelineStart = false)) + inner.emitPaginationStatus(PaginationStatus.Idle(hitTimelineStart = false)) // expect an item to be emitted, with an updated timestamp with(awaitItem()) { assertThat(size).isEqualTo(2) From 6e72454c1c76b596517ccb1b38c33bf7433f48a6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 09:40:42 +0100 Subject: [PATCH 06/20] Fix API break. --- .../impl/LinkNewDeviceFlowNode.kt | 6 +++- .../matrix/api/linknewdevice/ErrorType.kt | 30 +++++++++++++++---- .../HumanQrGrantLoginExceptionExtension.kt | 6 +++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index 52e93d4992..79a476ff04 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -189,9 +189,13 @@ class LinkNewDeviceFlowNode( is ErrorType.InvalidCheckCode -> ErrorScreenType.InsecureChannelDetected is ErrorType.MissingSecretsBackup -> ErrorScreenType.UnknownError is ErrorType.NotFound -> ErrorScreenType.Expired - is ErrorType.UnableToCreateDevice -> ErrorScreenType.UnknownError + is ErrorType.DeviceNotFound -> ErrorScreenType.UnknownError is ErrorType.Unknown -> ErrorScreenType.UnknownError is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError + is ErrorType.Cancelled -> ErrorScreenType.UnknownError + is ErrorType.ConnectionInsecure -> ErrorScreenType.InsecureChannelDetected + is ErrorType.Expired -> ErrorScreenType.Expired + is ErrorType.OtherDeviceAlreadySignedIn -> ErrorScreenType.UnknownError } // It is OK to push on backstack, since when user leaves the error screen, a new root will be set, // or the whole flow will be popped. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt index 0f61007d47..21c0d521c9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt @@ -33,13 +33,33 @@ sealed class ErrorType(message: String) : Exception(message) { */ class NotFound(message: String) : ErrorType(message) - /** - * The device could not be created. - */ - class UnableToCreateDevice(message: String) : ErrorType(message) - /** * An unknown error has happened. */ class Unknown(message: String) : ErrorType(message) + + /** + * The requested device was not returned by the homeserver. + */ + class DeviceNotFound(message: String) : ErrorType(message) + + /** + * The other device is already signed in and so does not need to sign in. + */ + class OtherDeviceAlreadySignedIn(message: String) : ErrorType(message) + + /** + * The sign in was cancelled. + */ + class Cancelled(message: String) : ErrorType(message) + + /** + * The sign in was not completed in the required time. + */ + class Expired(message: String) : ErrorType(message) + + /** + * A secure connection could not have been established between the two devices. + */ + class ConnectionInsecure(message: String) : ErrorType(message) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt index 2d47b60def..4027ee507b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt @@ -15,7 +15,11 @@ internal fun HumanQrGrantLoginException.map() = when (this) { is HumanQrGrantLoginException.InvalidCheckCode -> ErrorType.InvalidCheckCode(message.orEmpty()) is HumanQrGrantLoginException.MissingSecretsBackup -> ErrorType.MissingSecretsBackup(message.orEmpty()) is HumanQrGrantLoginException.NotFound -> ErrorType.NotFound(message.orEmpty()) - is HumanQrGrantLoginException.UnableToCreateDevice -> ErrorType.UnableToCreateDevice(message.orEmpty()) + is HumanQrGrantLoginException.Cancelled -> ErrorType.Cancelled(message.orEmpty()) + is HumanQrGrantLoginException.ConnectionInsecure -> ErrorType.ConnectionInsecure(message.orEmpty()) + is HumanQrGrantLoginException.DeviceNotFound -> ErrorType.DeviceNotFound(message.orEmpty()) + is HumanQrGrantLoginException.Expired -> ErrorType.Expired(message.orEmpty()) + is HumanQrGrantLoginException.OtherDeviceAlreadySignedIn -> ErrorType.OtherDeviceAlreadySignedIn(message.orEmpty()) is HumanQrGrantLoginException.Unknown -> ErrorType.Unknown(message.orEmpty()) is HumanQrGrantLoginException.UnsupportedProtocol -> ErrorType.UnsupportedProtocol(message.orEmpty()) } From 22fb9b7cc13fbe12d5a601aae97dd93f356fc2b3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 12:40:33 +0100 Subject: [PATCH 07/20] Import compound token v6.10.1 ./tools/compound/import_tokens.sh -b v6.10.1 --- .../compound/src/main/assets/theme.iife.js | 2 +- .../tokens/generated/CompoundIcons.kt | 87 ++++++++++++++++++- .../tokens/generated/SemanticColors.kt | 14 ++- .../tokens/generated/SemanticColorsDark.kt | 12 ++- .../tokens/generated/SemanticColorsDarkHc.kt | 12 ++- .../tokens/generated/SemanticColorsLight.kt | 12 ++- .../tokens/generated/SemanticColorsLightHc.kt | 12 ++- .../tokens/generated/TypographyTokens.kt | 2 +- .../generated/internal/DarkColorTokens.kt | 2 +- .../generated/internal/DarkHcColorTokens.kt | 2 +- .../generated/internal/LightColorTokens.kt | 2 +- .../generated/internal/LightHcColorTokens.kt | 2 +- .../ic_compound_advanced_settings.xml | 14 +++ .../res/drawable/ic_compound_backspace.xml | 14 +-- .../drawable/ic_compound_backspace_solid.xml | 5 +- .../src/main/res/drawable/ic_compound_bug.xml | 9 ++ .../src/main/res/drawable/ic_compound_mac.xml | 2 +- .../res/drawable/ic_compound_re_order.xml | 9 ++ .../res/drawable/ic_compound_rotate_left.xml | 9 ++ .../res/drawable/ic_compound_rotate_right.xml | 13 +++ .../main/res/drawable/ic_compound_section.xml | 9 ++ .../main/res/drawable/ic_compound_stop.xml | 9 ++ .../res/drawable/ic_compound_stop_solid.xml | 9 ++ .../main/res/drawable/ic_compound_theme.xml | 9 ++ .../res/drawable/ic_compound_translate.xml | 10 +++ .../main/res/drawable/ic_compound_tree.xml | 9 ++ .../ic_compound_video_call_outgoing_solid.xml | 11 +++ .../res/drawable/ic_compound_voice_call.xml | 12 +-- .../ic_compound_voice_call_declined_solid.xml | 10 +++ .../ic_compound_voice_call_missed_solid.xml | 13 +++ .../ic_compound_voice_call_outgoing_solid.xml | 13 +++ .../main/res/drawable/ic_compound_zoom_in.xml | 23 +++++ .../res/drawable/ic_compound_zoom_out.xml | 13 +++ tools/compound/addAutoMirrored.py | 4 + 34 files changed, 328 insertions(+), 62 deletions(-) create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_bug.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_re_order.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_section.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_stop.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_stop_solid.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_theme.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_translate.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_tree.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml create mode 100644 libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml diff --git a/libraries/compound/src/main/assets/theme.iife.js b/libraries/compound/src/main/assets/theme.iife.js index 71ce6b0013..1dd693cbd4 100644 --- a/libraries/compound/src/main/assets/theme.iife.js +++ b/libraries/compound/src/main/assets/theme.iife.js @@ -1 +1 @@ -var CompoundTheme=(function(Fe){"use strict";const Xe=(e,t=0,r=1)=>Zt(Jt(t,e),r),Yt=e=>{e._clipped=!1,e._unclipped=e.slice(0);for(let t=0;t<=3;t++)t<3?((e[t]<0||e[t]>255)&&(e._clipped=!0),e[t]=Xe(e[t],0,255)):t===3&&(e[t]=Xe(e[t],0,1));return e},co={};for(let e of["Boolean","Number","String","Function","Array","Date","RegExp","Undefined","Null"])co[`[object ${e}]`]=e.toLowerCase();function P(e){return co[Object.prototype.toString.call(e)]||"object"}const j=(e,t=null)=>e.length>=3?Array.prototype.slice.call(e):P(e[0])=="object"&&t?t.split("").filter(r=>e[0][r]!==void 0).map(r=>e[0][r]):e[0],pt=e=>{if(e.length<2)return null;const t=e.length-1;return P(e[t])=="string"?e[t].toLowerCase():null},{PI:mt,min:Zt,max:Jt}=Math,we=mt*2,Wt=mt/3,Ac=mt/180,Lc=180/mt,L={format:{},autodetect:[]};let $=class{constructor(...t){const r=this;if(P(t[0])==="object"&&t[0].constructor&&t[0].constructor===this.constructor)return t[0];let n=pt(t),o=!1;if(!n){o=!0,L.sorted||(L.autodetect=L.autodetect.sort((a,s)=>s.p-a.p),L.sorted=!0);for(let a of L.autodetect)if(n=a.test(...t),n)break}if(L.format[n]){const a=L.format[n].apply(null,o?t:t.slice(0,-1));r._rgb=Yt(a)}else throw new Error("unknown format: "+t);r._rgb.length===3&&r._rgb.push(1)}toString(){return P(this.hex)=="function"?this.hex():`[${this._rgb.join(",")}]`}};const Ec="2.6.0",O=(...e)=>new O.Color(...e);O.Color=$,O.version=Ec;const Tc=(...e)=>{e=j(e,"cmyk");const[t,r,n,o]=e,a=e.length>4?e[4]:1;return o===1?[0,0,0,a]:[t>=1?0:255*(1-t)*(1-o),r>=1?0:255*(1-r)*(1-o),n>=1?0:255*(1-n)*(1-o),a]},{max:io}=Math,Sc=(...e)=>{let[t,r,n]=j(e,"rgb");t=t/255,r=r/255,n=n/255;const o=1-io(t,io(r,n)),a=o<1?1/(1-o):0,s=(1-t-o)*a,c=(1-r-o)*a,i=(1-n-o)*a;return[s,c,i,o]};$.prototype.cmyk=function(){return Sc(this._rgb)},O.cmyk=(...e)=>new $(...e,"cmyk"),L.format.cmyk=Tc,L.autodetect.push({p:2,test:(...e)=>{if(e=j(e,"cmyk"),P(e)==="array"&&e.length===4)return"cmyk"}});const Ut=e=>Math.round(e*100)/100,Pc=(...e)=>{const t=j(e,"hsla");let r=pt(e)||"lsa";return t[0]=Ut(t[0]||0),t[1]=Ut(t[1]*100)+"%",t[2]=Ut(t[2]*100)+"%",r==="hsla"||t.length>3&&t[3]<1?(t[3]=t.length>3?t[3]:1,r="hsla"):t.length=3,`${r}(${t.join(",")})`},uo=(...e)=>{e=j(e,"rgba");let[t,r,n]=e;t/=255,r/=255,n/=255;const o=Zt(t,r,n),a=Jt(t,r,n),s=(a+o)/2;let c,i;return a===o?(c=0,i=Number.NaN):c=s<.5?(a-o)/(a+o):(a-o)/(2-a-o),t==a?i=(r-n)/(a-o):r==a?i=2+(n-t)/(a-o):n==a&&(i=4+(t-r)/(a-o)),i*=60,i<0&&(i+=360),e.length>3&&e[3]!==void 0?[i,c,s,e[3]]:[i,c,s]},{round:Qt}=Math,jc=(...e)=>{const t=j(e,"rgba");let r=pt(e)||"rgb";return r.substr(0,3)=="hsl"?Pc(uo(t),r):(t[0]=Qt(t[0]),t[1]=Qt(t[1]),t[2]=Qt(t[2]),(r==="rgba"||t.length>3&&t[3]<1)&&(t[3]=t.length>3?t[3]:1,r="rgba"),`${r}(${t.slice(0,r==="rgb"?3:4).join(",")})`)},{round:er}=Math,tr=(...e)=>{e=j(e,"hsl");const[t,r,n]=e;let o,a,s;if(r===0)o=a=s=n*255;else{const c=[0,0,0],i=[0,0,0],u=n<.5?n*(1+r):n+r-n*r,l=2*n-u,f=t/360;c[0]=f+1/3,c[1]=f,c[2]=f-1/3;for(let h=0;h<3;h++)c[h]<0&&(c[h]+=1),c[h]>1&&(c[h]-=1),6*c[h]<1?i[h]=l+(u-l)*6*c[h]:2*c[h]<1?i[h]=u:3*c[h]<2?i[h]=l+(u-l)*(2/3-c[h])*6:i[h]=l;[o,a,s]=[er(i[0]*255),er(i[1]*255),er(i[2]*255)]}return e.length>3?[o,a,s,e[3]]:[o,a,s,1]},fo=/^rgb\(\s*(-?\d+),\s*(-?\d+)\s*,\s*(-?\d+)\s*\)$/,lo=/^rgba\(\s*(-?\d+),\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*([01]|[01]?\.\d+)\)$/,ho=/^rgb\(\s*(-?\d+(?:\.\d+)?)%,\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*\)$/,bo=/^rgba\(\s*(-?\d+(?:\.\d+)?)%,\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,po=/^hsl\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*\)$/,mo=/^hsla\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,{round:go}=Math,rr=e=>{e=e.toLowerCase().trim();let t;if(L.format.named)try{return L.format.named(e)}catch{}if(t=e.match(fo)){const r=t.slice(1,4);for(let n=0;n<3;n++)r[n]=+r[n];return r[3]=1,r}if(t=e.match(lo)){const r=t.slice(1,5);for(let n=0;n<4;n++)r[n]=+r[n];return r}if(t=e.match(ho)){const r=t.slice(1,4);for(let n=0;n<3;n++)r[n]=go(r[n]*2.55);return r[3]=1,r}if(t=e.match(bo)){const r=t.slice(1,5);for(let n=0;n<3;n++)r[n]=go(r[n]*2.55);return r[3]=+r[3],r}if(t=e.match(po)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=tr(r);return n[3]=1,n}if(t=e.match(mo)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=tr(r);return n[3]=+t[4],n}};rr.test=e=>fo.test(e)||lo.test(e)||ho.test(e)||bo.test(e)||po.test(e)||mo.test(e),$.prototype.css=function(e){return jc(this._rgb,e)},O.css=(...e)=>new $(...e,"css"),L.format.css=rr,L.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&P(e)==="string"&&rr.test(e))return"css"}}),L.format.gl=(...e)=>{const t=j(e,"rgba");return t[0]*=255,t[1]*=255,t[2]*=255,t},O.gl=(...e)=>new $(...e,"gl"),$.prototype.gl=function(){const e=this._rgb;return[e[0]/255,e[1]/255,e[2]/255,e[3]]};const{floor:Bc}=Math,Gc=(...e)=>{e=j(e,"hcg");let[t,r,n]=e,o,a,s;n=n*255;const c=r*255;if(r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const i=Bc(t),u=t-i,l=n*(1-r),f=l+c*(1-u),h=l+c*u,d=l+c;switch(i){case 0:[o,a,s]=[d,h,l];break;case 1:[o,a,s]=[f,d,l];break;case 2:[o,a,s]=[l,d,h];break;case 3:[o,a,s]=[l,f,d];break;case 4:[o,a,s]=[h,l,d];break;case 5:[o,a,s]=[d,l,f];break}}return[o,a,s,e.length>3?e[3]:1]},Ic=(...e)=>{const[t,r,n]=j(e,"rgb"),o=Zt(t,r,n),a=Jt(t,r,n),s=a-o,c=s*100/255,i=o/(255-s)*100;let u;return s===0?u=Number.NaN:(t===a&&(u=(r-n)/s),r===a&&(u=2+(n-t)/s),n===a&&(u=4+(t-r)/s),u*=60,u<0&&(u+=360)),[u,c,i]};$.prototype.hcg=function(){return Ic(this._rgb)},O.hcg=(...e)=>new $(...e,"hcg"),L.format.hcg=Gc,L.autodetect.push({p:1,test:(...e)=>{if(e=j(e,"hcg"),P(e)==="array"&&e.length===3)return"hcg"}});const zc=/^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,Fc=/^#?([A-Fa-f0-9]{8}|[A-Fa-f0-9]{4})$/,vo=e=>{if(e.match(zc)){(e.length===4||e.length===7)&&(e=e.substr(1)),e.length===3&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]);const t=parseInt(e,16),r=t>>16,n=t>>8&255,o=t&255;return[r,n,o,1]}if(e.match(Fc)){(e.length===5||e.length===9)&&(e=e.substr(1)),e.length===4&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]+e[3]+e[3]);const t=parseInt(e,16),r=t>>24&255,n=t>>16&255,o=t>>8&255,a=Math.round((t&255)/255*100)/100;return[r,n,o,a]}throw new Error(`unknown hex color: ${e}`)},{round:gt}=Math,_o=(...e)=>{let[t,r,n,o]=j(e,"rgba"),a=pt(e)||"auto";o===void 0&&(o=1),a==="auto"&&(a=o<1?"rgba":"rgb"),t=gt(t),r=gt(r),n=gt(n);let c="000000"+(t<<16|r<<8|n).toString(16);c=c.substr(c.length-6);let i="0"+gt(o*255).toString(16);switch(i=i.substr(i.length-2),a.toLowerCase()){case"rgba":return`#${c}${i}`;case"argb":return`#${i}${c}`;default:return`#${c}`}};$.prototype.hex=function(e){return _o(this._rgb,e)},O.hex=(...e)=>new $(...e,"hex"),L.format.hex=vo,L.autodetect.push({p:4,test:(e,...t)=>{if(!t.length&&P(e)==="string"&&[3,4,5,6,7,8,9].indexOf(e.length)>=0)return"hex"}});const{cos:Ke}=Math,Xc=(...e)=>{e=j(e,"hsi");let[t,r,n]=e,o,a,s;return isNaN(t)&&(t=0),isNaN(r)&&(r=0),t>360&&(t-=360),t<0&&(t+=360),t/=360,t<1/3?(s=(1-r)/3,o=(1+r*Ke(we*t)/Ke(Wt-we*t))/3,a=1-(s+o)):t<2/3?(t-=1/3,o=(1-r)/3,a=(1+r*Ke(we*t)/Ke(Wt-we*t))/3,s=1-(o+a)):(t-=2/3,a=(1-r)/3,s=(1+r*Ke(we*t)/Ke(Wt-we*t))/3,o=1-(a+s)),o=Xe(n*o*3),a=Xe(n*a*3),s=Xe(n*s*3),[o*255,a*255,s*255,e.length>3?e[3]:1]},{min:Kc,sqrt:Dc,acos:Vc}=Math,Yc=(...e)=>{let[t,r,n]=j(e,"rgb");t/=255,r/=255,n/=255;let o;const a=Kc(t,r,n),s=(t+r+n)/3,c=s>0?1-a/s:0;return c===0?o=NaN:(o=(t-r+(t-n))/2,o/=Dc((t-r)*(t-r)+(t-n)*(r-n)),o=Vc(o),n>r&&(o=we-o),o/=we),[o*360,c,s]};$.prototype.hsi=function(){return Yc(this._rgb)},O.hsi=(...e)=>new $(...e,"hsi"),L.format.hsi=Xc,L.autodetect.push({p:2,test:(...e)=>{if(e=j(e,"hsi"),P(e)==="array"&&e.length===3)return"hsi"}}),$.prototype.hsl=function(){return uo(this._rgb)},O.hsl=(...e)=>new $(...e,"hsl"),L.format.hsl=tr,L.autodetect.push({p:2,test:(...e)=>{if(e=j(e,"hsl"),P(e)==="array"&&e.length===3)return"hsl"}});const{floor:Zc}=Math,Jc=(...e)=>{e=j(e,"hsv");let[t,r,n]=e,o,a,s;if(n*=255,r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const c=Zc(t),i=t-c,u=n*(1-r),l=n*(1-r*i),f=n*(1-r*(1-i));switch(c){case 0:[o,a,s]=[n,f,u];break;case 1:[o,a,s]=[l,n,u];break;case 2:[o,a,s]=[u,n,f];break;case 3:[o,a,s]=[u,l,n];break;case 4:[o,a,s]=[f,u,n];break;case 5:[o,a,s]=[n,u,l];break}}return[o,a,s,e.length>3?e[3]:1]},{min:Wc,max:Uc}=Math,Qc=(...e)=>{e=j(e,"rgb");let[t,r,n]=e;const o=Wc(t,r,n),a=Uc(t,r,n),s=a-o;let c,i,u;return u=a/255,a===0?(c=Number.NaN,i=0):(i=s/a,t===a&&(c=(r-n)/s),r===a&&(c=2+(n-t)/s),n===a&&(c=4+(t-r)/s),c*=60,c<0&&(c+=360)),[c,i,u]};$.prototype.hsv=function(){return Qc(this._rgb)},O.hsv=(...e)=>new $(...e,"hsv"),L.format.hsv=Jc,L.autodetect.push({p:2,test:(...e)=>{if(e=j(e,"hsv"),P(e)==="array"&&e.length===3)return"hsv"}});const se={Kn:18,Xn:.95047,Yn:1,Zn:1.08883,t0:.137931034,t1:.206896552,t2:.12841855,t3:.008856452},{pow:e0}=Math,yo=(...e)=>{e=j(e,"lab");const[t,r,n]=e;let o,a,s,c,i,u;return a=(t+16)/116,o=isNaN(r)?a:a+r/500,s=isNaN(n)?a:a-n/200,a=se.Yn*or(a),o=se.Xn*or(o),s=se.Zn*or(s),c=nr(3.2404542*o-1.5371385*a-.4985314*s),i=nr(-.969266*o+1.8760108*a+.041556*s),u=nr(.0556434*o-.2040259*a+1.0572252*s),[c,i,u,e.length>3?e[3]:1]},nr=e=>255*(e<=.00304?12.92*e:1.055*e0(e,1/2.4)-.055),or=e=>e>se.t1?e*e*e:se.t2*(e-se.t0),{pow:wo}=Math,ko=(...e)=>{const[t,r,n]=j(e,"rgb"),[o,a,s]=t0(t,r,n),c=116*a-16;return[c<0?0:c,500*(o-a),200*(a-s)]},sr=e=>(e/=255)<=.04045?e/12.92:wo((e+.055)/1.055,2.4),ar=e=>e>se.t3?wo(e,1/3):e/se.t2+se.t0,t0=(e,t,r)=>{e=sr(e),t=sr(t),r=sr(r);const n=ar((.4124564*e+.3575761*t+.1804375*r)/se.Xn),o=ar((.2126729*e+.7151522*t+.072175*r)/se.Yn),a=ar((.0193339*e+.119192*t+.9503041*r)/se.Zn);return[n,o,a]};$.prototype.lab=function(){return ko(this._rgb)},O.lab=(...e)=>new $(...e,"lab"),L.format.lab=yo,L.autodetect.push({p:2,test:(...e)=>{if(e=j(e,"lab"),P(e)==="array"&&e.length===3)return"lab"}});const{sin:r0,cos:n0}=Math,$o=(...e)=>{let[t,r,n]=j(e,"lch");return isNaN(n)&&(n=0),n=n*Ac,[t,n0(n)*r,r0(n)*r]},Co=(...e)=>{e=j(e,"lch");const[t,r,n]=e,[o,a,s]=$o(t,r,n),[c,i,u]=yo(o,a,s);return[c,i,u,e.length>3?e[3]:1]},o0=(...e)=>{const t=j(e,"hcl").reverse();return Co(...t)},{sqrt:s0,atan2:a0,round:c0}=Math,xo=(...e)=>{const[t,r,n]=j(e,"lab"),o=s0(r*r+n*n);let a=(a0(n,r)*Lc+360)%360;return c0(o*1e4)===0&&(a=Number.NaN),[t,o,a]},Ro=(...e)=>{const[t,r,n]=j(e,"rgb"),[o,a,s]=ko(t,r,n);return xo(o,a,s)};$.prototype.lch=function(){return Ro(this._rgb)},$.prototype.hcl=function(){return Ro(this._rgb).reverse()},O.lch=(...e)=>new $(...e,"lch"),O.hcl=(...e)=>new $(...e,"hcl"),L.format.lch=Co,L.format.hcl=o0,["lch","hcl"].forEach(e=>L.autodetect.push({p:2,test:(...t)=>{if(t=j(t,e),P(t)==="array"&&t.length===3)return e}}));const De={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",laserlemon:"#ffff54",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrod:"#fafad2",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",maroon2:"#7f0000",maroon3:"#b03060",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",purple2:"#7f007f",purple3:"#a020f0",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};$.prototype.name=function(){const e=_o(this._rgb,"rgb");for(let t of Object.keys(De))if(De[t]===e)return t.toLowerCase();return e},L.format.named=e=>{if(e=e.toLowerCase(),De[e])return vo(De[e]);throw new Error("unknown color name: "+e)},L.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&P(e)==="string"&&De[e.toLowerCase()])return"named"}});const i0=e=>{if(P(e)=="number"&&e>=0&&e<=16777215){const t=e>>16,r=e>>8&255,n=e&255;return[t,r,n,1]}throw new Error("unknown num color: "+e)},u0=(...e)=>{const[t,r,n]=j(e,"rgb");return(t<<16)+(r<<8)+n};$.prototype.num=function(){return u0(this._rgb)},O.num=(...e)=>new $(...e,"num"),L.format.num=i0,L.autodetect.push({p:5,test:(...e)=>{if(e.length===1&&P(e[0])==="number"&&e[0]>=0&&e[0]<=16777215)return"num"}});const{round:Ho}=Math;$.prototype.rgb=function(e=!0){return e===!1?this._rgb.slice(0,3):this._rgb.slice(0,3).map(Ho)},$.prototype.rgba=function(e=!0){return this._rgb.slice(0,4).map((t,r)=>r<3?e===!1?t:Ho(t):t)},O.rgb=(...e)=>new $(...e,"rgb"),L.format.rgb=(...e)=>{const t=j(e,"rgba");return t[3]===void 0&&(t[3]=1),t},L.autodetect.push({p:3,test:(...e)=>{if(e=j(e,"rgba"),P(e)==="array"&&(e.length===3||e.length===4&&P(e[3])=="number"&&e[3]>=0&&e[3]<=1))return"rgb"}});const{log:vt}=Math,qo=e=>{const t=e/100;let r,n,o;return t<66?(r=255,n=t<6?0:-155.25485562709179-.44596950469579133*(n=t-2)+104.49216199393888*vt(n),o=t<20?0:-254.76935184120902+.8274096064007395*(o=t-10)+115.67994401066147*vt(o)):(r=351.97690566805693+.114206453784165*(r=t-55)-40.25366309332127*vt(r),n=325.4494125711974+.07943456536662342*(n=t-50)-28.0852963507957*vt(n),o=255),[r,n,o,1]},{round:f0}=Math,l0=(...e)=>{const t=j(e,"rgb"),r=t[0],n=t[2];let o=1e3,a=4e4;const s=.4;let c;for(;a-o>s;){c=(a+o)*.5;const i=qo(c);i[2]/i[0]>=n/r?a=c:o=c}return f0(c)};$.prototype.temp=$.prototype.kelvin=$.prototype.temperature=function(){return l0(this._rgb)},O.temp=O.kelvin=O.temperature=(...e)=>new $(...e,"temp"),L.format.temp=L.format.kelvin=L.format.temperature=qo;const{pow:_t,sign:h0}=Math,Mo=(...e)=>{e=j(e,"lab");const[t,r,n]=e,o=_t(t+.3963377774*r+.2158037573*n,3),a=_t(t-.1055613458*r-.0638541728*n,3),s=_t(t-.0894841775*r-1.291485548*n,3);return[255*cr(4.0767416621*o-3.3077115913*a+.2309699292*s),255*cr(-1.2684380046*o+2.6097574011*a-.3413193965*s),255*cr(-.0041960863*o-.7034186147*a+1.707614701*s),e.length>3?e[3]:1]};function cr(e){const t=Math.abs(e);return t>.0031308?(h0(e)||1)*(1.055*_t(t,.4166666666666667)-.055):e*12.92}const{cbrt:ir,pow:d0,sign:b0}=Math,Oo=(...e)=>{const[t,r,n]=j(e,"rgb"),[o,a,s]=[ur(t/255),ur(r/255),ur(n/255)],c=ir(.4122214708*o+.5363325363*a+.0514459929*s),i=ir(.2119034982*o+.6806995451*a+.1073969566*s),u=ir(.0883024619*o+.2817188376*a+.6299787005*s);return[.2104542553*c+.793617785*i-.0040720468*u,1.9779984951*c-2.428592205*i+.4505937099*u,.0259040371*c+.7827717662*i-.808675766*u]};function ur(e){const t=Math.abs(e);return t<.04045?e/12.92:(b0(e)||1)*d0((t+.055)/1.055,2.4)}$.prototype.oklab=function(){return Oo(this._rgb)},O.oklab=(...e)=>new $(...e,"oklab"),L.format.oklab=Mo,L.autodetect.push({p:3,test:(...e)=>{if(e=j(e,"oklab"),P(e)==="array"&&e.length===3)return"oklab"}});const p0=(...e)=>{e=j(e,"lch");const[t,r,n]=e,[o,a,s]=$o(t,r,n),[c,i,u]=Mo(o,a,s);return[c,i,u,e.length>3?e[3]:1]},m0=(...e)=>{const[t,r,n]=j(e,"rgb"),[o,a,s]=Oo(t,r,n);return xo(o,a,s)};$.prototype.oklch=function(){return m0(this._rgb)},O.oklch=(...e)=>new $(...e,"oklch"),L.format.oklch=p0,L.autodetect.push({p:3,test:(...e)=>{if(e=j(e,"oklch"),P(e)==="array"&&e.length===3)return"oklch"}}),$.prototype.alpha=function(e,t=!1){return e!==void 0&&P(e)==="number"?t?(this._rgb[3]=e,this):new $([this._rgb[0],this._rgb[1],this._rgb[2],e],"rgb"):this._rgb[3]},$.prototype.clipped=function(){return this._rgb._clipped||!1},$.prototype.darken=function(e=1){const t=this,r=t.lab();return r[0]-=se.Kn*e,new $(r,"lab").alpha(t.alpha(),!0)},$.prototype.brighten=function(e=1){return this.darken(-e)},$.prototype.darker=$.prototype.darken,$.prototype.brighter=$.prototype.brighten,$.prototype.get=function(e){const[t,r]=e.split("."),n=this[t]();if(r){const o=t.indexOf(r)-(t.substr(0,2)==="ok"?2:0);if(o>-1)return n[o];throw new Error(`unknown channel ${r} in mode ${t}`)}else return n};const{pow:g0}=Math,v0=1e-7,_0=20;$.prototype.luminance=function(e,t="rgb"){if(e!==void 0&&P(e)==="number"){if(e===0)return new $([0,0,0,this._rgb[3]],"rgb");if(e===1)return new $([255,255,255,this._rgb[3]],"rgb");let r=this.luminance(),n=_0;const o=(s,c)=>{const i=s.interpolate(c,.5,t),u=i.luminance();return Math.abs(e-u)e?o(s,i):o(i,c)},a=(r>e?o(new $([0,0,0]),this):o(this,new $([255,255,255]))).rgb();return new $([...a,this._rgb[3]])}return y0(...this._rgb.slice(0,3))};const y0=(e,t,r)=>(e=fr(e),t=fr(t),r=fr(r),.2126*e+.7152*t+.0722*r),fr=e=>(e/=255,e<=.03928?e/12.92:g0((e+.055)/1.055,2.4)),re={},ct=(e,t,r=.5,...n)=>{let o=n[0]||"lrgb";if(!re[o]&&!n.length&&(o=Object.keys(re)[0]),!re[o])throw new Error(`interpolation mode ${o} is not defined`);return P(e)!=="object"&&(e=new $(e)),P(t)!=="object"&&(t=new $(t)),re[o](e,t,r).alpha(e.alpha()+r*(t.alpha()-e.alpha()))};$.prototype.mix=$.prototype.interpolate=function(e,t=.5,...r){return ct(this,e,t,...r)},$.prototype.premultiply=function(e=!1){const t=this._rgb,r=t[3];return e?(this._rgb=[t[0]*r,t[1]*r,t[2]*r,r],this):new $([t[0]*r,t[1]*r,t[2]*r,r],"rgb")},$.prototype.saturate=function(e=1){const t=this,r=t.lch();return r[1]+=se.Kn*e,r[1]<0&&(r[1]=0),new $(r,"lch").alpha(t.alpha(),!0)},$.prototype.desaturate=function(e=1){return this.saturate(-e)},$.prototype.set=function(e,t,r=!1){const[n,o]=e.split("."),a=this[n]();if(o){const s=n.indexOf(o)-(n.substr(0,2)==="ok"?2:0);if(s>-1){if(P(t)=="string")switch(t.charAt(0)){case"+":a[s]+=+t;break;case"-":a[s]+=+t;break;case"*":a[s]*=+t.substr(1);break;case"/":a[s]/=+t.substr(1);break;default:a[s]=+t}else if(P(t)==="number")a[s]=t;else throw new Error("unsupported value for Color.set");const c=new $(a,n);return r?(this._rgb=c._rgb,this):c}throw new Error(`unknown channel ${o} in mode ${n}`)}else return a},$.prototype.tint=function(e=.5,...t){return ct(this,"white",e,...t)},$.prototype.shade=function(e=.5,...t){return ct(this,"black",e,...t)};const w0=(e,t,r)=>{const n=e._rgb,o=t._rgb;return new $(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"rgb")};re.rgb=w0;const{sqrt:lr,pow:Ve}=Math,k0=(e,t,r)=>{const[n,o,a]=e._rgb,[s,c,i]=t._rgb;return new $(lr(Ve(n,2)*(1-r)+Ve(s,2)*r),lr(Ve(o,2)*(1-r)+Ve(c,2)*r),lr(Ve(a,2)*(1-r)+Ve(i,2)*r),"rgb")};re.lrgb=k0;const $0=(e,t,r)=>{const n=e.lab(),o=t.lab();return new $(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"lab")};re.lab=$0;const Ye=(e,t,r,n)=>{let o,a;n==="hsl"?(o=e.hsl(),a=t.hsl()):n==="hsv"?(o=e.hsv(),a=t.hsv()):n==="hcg"?(o=e.hcg(),a=t.hcg()):n==="hsi"?(o=e.hsi(),a=t.hsi()):n==="lch"||n==="hcl"?(n="hcl",o=e.hcl(),a=t.hcl()):n==="oklch"&&(o=e.oklch().reverse(),a=t.oklch().reverse());let s,c,i,u,l,f;(n.substr(0,1)==="h"||n==="oklch")&&([s,i,l]=o,[c,u,f]=a);let h,d,b,_;return!isNaN(s)&&!isNaN(c)?(c>s&&c-s>180?_=c-(s+360):c180?_=c+360-s:_=c-s,d=s+r*_):isNaN(s)?isNaN(c)?d=Number.NaN:(d=c,(l==1||l==0)&&n!="hsv"&&(h=u)):(d=s,(f==1||f==0)&&n!="hsv"&&(h=i)),h===void 0&&(h=i+r*(u-i)),b=l+r*(f-l),n==="oklch"?new $([b,h,d],n):new $([d,h,b],n)},No=(e,t,r)=>Ye(e,t,r,"lch");re.lch=No,re.hcl=No;const C0=(e,t,r)=>{const n=e.num(),o=t.num();return new $(n+r*(o-n),"num")};re.num=C0;const x0=(e,t,r)=>Ye(e,t,r,"hcg");re.hcg=x0;const R0=(e,t,r)=>Ye(e,t,r,"hsi");re.hsi=R0;const H0=(e,t,r)=>Ye(e,t,r,"hsl");re.hsl=H0;const q0=(e,t,r)=>Ye(e,t,r,"hsv");re.hsv=q0;const M0=(e,t,r)=>{const n=e.oklab(),o=t.oklab();return new $(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"oklab")};re.oklab=M0;const O0=(e,t,r)=>Ye(e,t,r,"oklch");re.oklch=O0;const{pow:hr,sqrt:dr,PI:br,cos:Ao,sin:Lo,atan2:N0}=Math,A0=(e,t="lrgb",r=null)=>{const n=e.length;r||(r=Array.from(new Array(n)).map(()=>1));const o=n/r.reduce(function(f,h){return f+h});if(r.forEach((f,h)=>{r[h]*=o}),e=e.map(f=>new $(f)),t==="lrgb")return L0(e,r);const a=e.shift(),s=a.get(t),c=[];let i=0,u=0;for(let f=0;f{const d=f.get(t);l+=f.alpha()*r[h+1];for(let b=0;b=360;)h-=360;s[f]=h}else s[f]=s[f]/c[f];return l/=n,new $(s,t).alpha(l>.99999?1:l,!0)},L0=(e,t)=>{const r=e.length,n=[0,0,0,0];for(let o=0;o.9999999&&(n[3]=1),new $(Yt(n))},{pow:E0}=Math;function yt(e){let t="rgb",r=O("#ccc"),n=0,o=[0,1],a=[],s=[0,0],c=!1,i=[],u=!1,l=0,f=1,h=!1,d={},b=!0,_=1;const g=function(m){if(m=m||["#fff","#000"],m&&P(m)==="string"&&O.brewer&&O.brewer[m.toLowerCase()]&&(m=O.brewer[m.toLowerCase()]),P(m)==="array"){m.length===1&&(m=[m[0],m[0]]),m=m.slice(0);for(let p=0;p=c[y];)y++;return y-1}return 0};let H=m=>m,x=m=>m;const N=function(m,p){let y,w;if(p==null&&(p=!1),isNaN(m)||m===null)return r;p?w=m:c&&c.length>2?w=v(m)/(c.length-2):f!==l?w=(m-l)/(f-l):w=1,w=x(w),p||(w=H(w)),_!==1&&(w=E0(w,_)),w=s[0]+w*(1-s[0]-s[1]),w=Xe(w,0,1);const C=Math.floor(w*1e4);if(b&&d[C])y=d[C];else{if(P(i)==="array")for(let q=0;q=M&&q===a.length-1){y=i[q];break}if(w>M&&wd={};g(e);const R=function(m){const p=O(N(m));return u&&p[u]?p[u]():p};return R.classes=function(m){if(m!=null){if(P(m)==="array")c=m,o=[m[0],m[m.length-1]];else{const p=O.analyze(o);m===0?c=[p.min,p.max]:c=O.limits(p,"e",m)}return R}return c},R.domain=function(m){if(!arguments.length)return o;l=m[0],f=m[m.length-1],a=[];const p=i.length;if(m.length===p&&l!==f)for(let y of Array.from(m))a.push((y-l)/(f-l));else{for(let y=0;y2){const y=m.map((C,q)=>q/(m.length-1)),w=m.map(C=>(C-l)/(f-l));w.every((C,q)=>y[q]===C)||(x=C=>{if(C<=0||C>=1)return C;let q=0;for(;C>=w[q+1];)q++;const M=(C-w[q])/(w[q+1]-w[q]);return y[q]+M*(y[q+1]-y[q])})}}return o=[l,f],R},R.mode=function(m){return arguments.length?(t=m,A(),R):t},R.range=function(m,p){return g(m),R},R.out=function(m){return u=m,R},R.spread=function(m){return arguments.length?(n=m,R):n},R.correctLightness=function(m){return m==null&&(m=!0),h=m,A(),h?H=function(p){const y=N(0,!0).lab()[0],w=N(1,!0).lab()[0],C=y>w;let q=N(p,!0).lab()[0];const M=y+(w-y)*p;let S=q-M,X=0,D=1,U=20;for(;Math.abs(S)>.01&&U-- >0;)(function(){return C&&(S*=-1),S<0?(X=p,p+=(D-p)*.5):(D=p,p+=(X-p)*.5),q=N(p,!0).lab()[0],S=q-M})();return p}:H=p=>p,R},R.padding=function(m){return m!=null?(P(m)==="number"&&(m=[m,m]),s=m,R):s},R.colors=function(m,p){arguments.length<2&&(p="hex");let y=[];if(arguments.length===0)y=i.slice(0);else if(m===1)y=[R(.5)];else if(m>1){const w=o[0],C=o[1]-w;y=T0(0,m).map(q=>R(w+q/(m-1)*C))}else{e=[];let w=[];if(c&&c.length>2)for(let C=1,q=c.length,M=1<=q;M?Cq;M?C++:C--)w.push((c[C-1]+c[C])*.5);else w=o;y=w.map(C=>R(C))}return O[p]&&(y=y.map(w=>w[p]())),y},R.cache=function(m){return m!=null?(b=m,R):b},R.gamma=function(m){return m!=null?(_=m,R):_},R.nodata=function(m){return m!=null?(r=O(m),R):r},R}function T0(e,t,r){let n=[],o=ea;o?s++:s--)n.push(s);return n}const S0=function(e){let t=[1,1];for(let r=1;rnew $(a)),e.length===2)[r,n]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>r[c]+a*(n[c]-r[c]));return new $(s,"lab")};else if(e.length===3)[r,n,o]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>(1-a)*(1-a)*r[c]+2*(1-a)*a*n[c]+a*a*o[c]);return new $(s,"lab")};else if(e.length===4){let a;[r,n,o,a]=e.map(s=>s.lab()),t=function(s){const c=[0,1,2].map(i=>(1-s)*(1-s)*(1-s)*r[i]+3*(1-s)*(1-s)*s*n[i]+3*(1-s)*s*s*o[i]+s*s*s*a[i]);return new $(c,"lab")}}else if(e.length>=5){let a,s,c;a=e.map(i=>i.lab()),c=e.length-1,s=S0(c),t=function(i){const u=1-i,l=[0,1,2].map(f=>a.reduce((h,d,b)=>h+s[b]*u**(c-b)*i**b*d[f],0));return new $(l,"lab")}}else throw new RangeError("No point in running bezier with only one color.");return t},j0=e=>{const t=P0(e);return t.scale=()=>yt(t),t},de=(e,t,r)=>{if(!de[r])throw new Error("unknown blend mode "+r);return de[r](e,t)},Ne=e=>(t,r)=>{const n=O(r).rgb(),o=O(t).rgb();return O.rgb(e(n,o))},Ae=e=>(t,r)=>{const n=[];return n[0]=e(t[0],r[0]),n[1]=e(t[1],r[1]),n[2]=e(t[2],r[2]),n},B0=e=>e,G0=(e,t)=>e*t/255,I0=(e,t)=>e>t?t:e,z0=(e,t)=>e>t?e:t,F0=(e,t)=>255*(1-(1-e/255)*(1-t/255)),X0=(e,t)=>t<128?2*e*t/255:255*(1-2*(1-e/255)*(1-t/255)),K0=(e,t)=>255*(1-(1-t/255)/(e/255)),D0=(e,t)=>e===255?255:(e=255*(t/255)/(1-e/255),e>255?255:e);de.normal=Ne(Ae(B0)),de.multiply=Ne(Ae(G0)),de.screen=Ne(Ae(F0)),de.overlay=Ne(Ae(X0)),de.darken=Ne(Ae(I0)),de.lighten=Ne(Ae(z0)),de.dodge=Ne(Ae(D0)),de.burn=Ne(Ae(K0));const{pow:V0,sin:Y0,cos:Z0}=Math;function J0(e=300,t=-1.5,r=1,n=1,o=[0,1]){let a=0,s;P(o)==="array"?s=o[1]-o[0]:(s=0,o=[o,o]);const c=function(i){const u=we*((e+120)/360+t*i),l=V0(o[0]+s*i,n),h=(a!==0?r[0]+i*a:r)*l*(1-l)/2,d=Z0(u),b=Y0(u),_=l+h*(-.14861*d+1.78277*b),g=l+h*(-.29227*d-.90649*b),v=l+h*(1.97294*d);return O(Yt([_*255,g*255,v*255,1]))};return c.start=function(i){return i==null?e:(e=i,c)},c.rotations=function(i){return i==null?t:(t=i,c)},c.gamma=function(i){return i==null?n:(n=i,c)},c.hue=function(i){return i==null?r:(r=i,P(r)==="array"?(a=r[1]-r[0],a===0&&(r=r[1])):a=0,c)},c.lightness=function(i){return i==null?o:(P(i)==="array"?(o=i,s=i[1]-i[0]):(o=[i,i],s=0),c)},c.scale=()=>O.scale(c),c.hue(r),c}const W0="0123456789abcdef",{floor:U0,random:Q0}=Math,ei=()=>{let e="#";for(let t=0;t<6;t++)e+=W0.charAt(U0(Q0()*16));return new $(e,"hex")},{log:Eo,pow:ti,floor:ri,abs:ni}=Math;function To(e,t=null){const r={min:Number.MAX_VALUE,max:Number.MAX_VALUE*-1,sum:0,values:[],count:0};return P(e)==="object"&&(e=Object.values(e)),e.forEach(n=>{t&&P(n)==="object"&&(n=n[t]),n!=null&&!isNaN(n)&&(r.values.push(n),r.sum+=n,nr.max&&(r.max=n),r.count+=1)}),r.domain=[r.min,r.max],r.limits=(n,o)=>So(r,n,o),r}function So(e,t="equal",r=7){P(e)=="array"&&(e=To(e));const{min:n,max:o}=e,a=e.values.sort((c,i)=>c-i);if(r===1)return[n,o];const s=[];if(t.substr(0,1)==="c"&&(s.push(n),s.push(o)),t.substr(0,1)==="e"){s.push(n);for(let c=1;c 0");const c=Math.LOG10E*Eo(n),i=Math.LOG10E*Eo(o);s.push(n);for(let u=1;u200&&(f=!1)}const b={};for(let g=0;gg-v),s.push(_[0]);for(let g=1;g<_.length;g+=2){const v=_[g];!isNaN(v)&&s.indexOf(v)===-1&&s.push(v)}}return s}const oi=(e,t)=>{e=new $(e),t=new $(t);const r=e.luminance(),n=t.luminance();return r>n?(r+.05)/(n+.05):(n+.05)/(r+.05)},{sqrt:ke,pow:V,min:si,max:ai,atan2:Po,abs:jo,cos:wt,sin:Bo,exp:ci,PI:Go}=Math;function ii(e,t,r=1,n=1,o=1){var a=function(he){return 360*he/(2*Go)},s=function(he){return 2*Go*he/360};e=new $(e),t=new $(t);const[c,i,u]=Array.from(e.lab()),[l,f,h]=Array.from(t.lab()),d=(c+l)/2,b=ke(V(i,2)+V(u,2)),_=ke(V(f,2)+V(h,2)),g=(b+_)/2,v=.5*(1-ke(V(g,7)/(V(g,7)+V(25,7)))),H=i*(1+v),x=f*(1+v),N=ke(V(H,2)+V(u,2)),A=ke(V(x,2)+V(h,2)),R=(N+A)/2,m=a(Po(u,H)),p=a(Po(h,x)),y=m>=0?m:m+360,w=p>=0?p:p+360,C=jo(y-w)>180?(y+w+360)/2:(y+w)/2,q=1-.17*wt(s(C-30))+.24*wt(s(2*C))+.32*wt(s(3*C+6))-.2*wt(s(4*C-63));let M=w-y;M=jo(M)<=180?M:w<=y?M+360:M-360,M=2*ke(N*A)*Bo(s(M)/2);const S=l-c,X=A-N,D=1+.015*V(d-50,2)/ke(20+V(d-50,2)),U=1+.045*R,ce=1+.015*R*q,ye=30*ci(-V((C-275)/25,2)),le=-(2*ke(V(R,7)/(V(R,7)+V(25,7))))*Bo(2*s(ye)),qe=ke(V(S/(r*D),2)+V(X/(n*U),2)+V(M/(o*ce),2)+le*(X/(n*U))*(M/(o*ce)));return ai(0,si(100,qe))}function ui(e,t,r="lab"){e=new $(e),t=new $(t);const n=e.get(r),o=t.get(r);let a=0;for(let s in n){const c=(n[s]||0)-(o[s]||0);a+=c*c}return Math.sqrt(a)}const fi=(...e)=>{try{return new $(...e),!0}catch{return!1}},li={cool(){return yt([O.hsl(180,1,.9),O.hsl(250,.7,.4)])},hot(){return yt(["#000","#f00","#ff0","#fff"]).mode("rgb")}},kt={OrRd:["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"],PuBu:["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#045a8d","#023858"],BuPu:["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#810f7c","#4d004b"],Oranges:["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"],BuGn:["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#006d2c","#00441b"],YlOrBr:["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#993404","#662506"],YlGn:["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#006837","#004529"],Reds:["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"],RdPu:["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177","#49006a"],Greens:["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"],YlGnBu:["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"],Purples:["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"],GnBu:["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#0868ac","#084081"],Greys:["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"],YlOrRd:["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#bd0026","#800026"],PuRd:["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#980043","#67001f"],Blues:["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"],PuBuGn:["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016c59","#014636"],Viridis:["#440154","#482777","#3f4a8a","#31678e","#26838f","#1f9d8a","#6cce5a","#b6de2b","#fee825"],Spectral:["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"],RdYlGn:["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"],RdBu:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"],PiYG:["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"],PRGn:["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"],RdYlBu:["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"],BrBG:["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"],RdGy:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"],PuOr:["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"],Set2:["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"],Accent:["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"],Set1:["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"],Set3:["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"],Dark2:["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"],Paired:["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"],Pastel2:["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"],Pastel1:["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"]};for(let e of Object.keys(kt))kt[e.toLowerCase()]=kt[e];Object.assign(O,{average:A0,bezier:j0,blend:de,cubehelix:J0,mix:ct,interpolate:ct,random:ei,scale:yt,analyze:To,contrast:oi,deltaE:ii,distance:ui,limits:So,valid:fi,scales:li,input:L,colors:De,brewer:kt});function pr(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var mr,Io;function hi(){if(Io)return mr;Io=1;var e=e||{};e.Geometry=function(){},e.Geometry.intersectLineLine=function(r,n){var o=(r.intercept-n.intercept)/(n.slope-r.slope),a=r.slope*o+r.intercept;return{x:o,y:a}},e.Geometry.distanceFromOrigin=function(r){return Math.sqrt(Math.pow(r.x,2)+Math.pow(r.y,2))},e.Geometry.distanceLineFromOrigin=function(r){return Math.abs(r.intercept)/Math.sqrt(Math.pow(r.slope,2)+1)},e.Geometry.perpendicularThroughPoint=function(r,n){var o=-1/r.slope,a=n.y-o*n.x;return{slope:o,intercept:a}},e.Geometry.angleFromOrigin=function(r){return Math.atan2(r.y,r.x)},e.Geometry.normalizeAngle=function(r){var n=2*Math.PI;return(r%n+n)%n},e.Geometry.lengthOfRayUntilIntersect=function(r,n){return n.intercept/(Math.sin(r)-n.slope*Math.cos(r))},e.Hsluv=function(){},e.Hsluv.getBounds=function(r){for(var n=[],o=Math.pow(r+16,3)/1560896,a=o>e.Hsluv.epsilon?o:r/e.Hsluv.kappa,s=0;s<3;)for(var c=s++,i=e.Hsluv.m[c][0],u=e.Hsluv.m[c][1],l=e.Hsluv.m[c][2],f=0;f<2;){var h=f++,d=(284517*i-94839*l)*a,b=(838422*l+769860*u+731718*i)*r*a-769860*h*r,_=(632260*l-126452*u)*a+126452*h;n.push({slope:d/_,intercept:b/_})}return n},e.Hsluv.maxSafeChromaForL=function(r){for(var n=e.Hsluv.getBounds(r),o=1/0,a=0;a=0&&(s=Math.min(s,u))}return s},e.Hsluv.dotProduct=function(r,n){for(var o=0,a=0,s=r.length;a.04045?Math.pow((r+.055)/1.055,2.4):r/12.92},e.Hsluv.xyzToRgb=function(r){return[e.Hsluv.fromLinear(e.Hsluv.dotProduct(e.Hsluv.m[0],r)),e.Hsluv.fromLinear(e.Hsluv.dotProduct(e.Hsluv.m[1],r)),e.Hsluv.fromLinear(e.Hsluv.dotProduct(e.Hsluv.m[2],r))]},e.Hsluv.rgbToXyz=function(r){var n=[e.Hsluv.toLinear(r[0]),e.Hsluv.toLinear(r[1]),e.Hsluv.toLinear(r[2])];return[e.Hsluv.dotProduct(e.Hsluv.minv[0],n),e.Hsluv.dotProduct(e.Hsluv.minv[1],n),e.Hsluv.dotProduct(e.Hsluv.minv[2],n)]},e.Hsluv.yToL=function(r){return r<=e.Hsluv.epsilon?r/e.Hsluv.refY*e.Hsluv.kappa:116*Math.pow(r/e.Hsluv.refY,.3333333333333333)-16},e.Hsluv.lToY=function(r){return r<=8?e.Hsluv.refY*r/e.Hsluv.kappa:e.Hsluv.refY*Math.pow((r+16)/116,3)},e.Hsluv.xyzToLuv=function(r){var n=r[0],o=r[1],a=r[2],s=n+15*o+3*a,c=4*n,i=9*o;s!=0?(c/=s,i/=s):(c=NaN,i=NaN);var u=e.Hsluv.yToL(o);if(u==0)return[0,0,0];var l=13*u*(c-e.Hsluv.refU),f=13*u*(i-e.Hsluv.refV);return[u,l,f]},e.Hsluv.luvToXyz=function(r){var n=r[0],o=r[1],a=r[2];if(n==0)return[0,0,0];var s=o/(13*n)+e.Hsluv.refU,c=a/(13*n)+e.Hsluv.refV,i=e.Hsluv.lToY(n),u=0-9*i*s/((s-4)*c-s*c),l=(9*i-15*c*i-c*u)/(3*c);return[u,i,l]},e.Hsluv.luvToLch=function(r){var n=r[0],o=r[1],a=r[2],s=Math.sqrt(o*o+a*a),c;if(s<1e-8)c=0;else{var i=Math.atan2(a,o);c=i*180/Math.PI,c<0&&(c=360+c)}return[n,s,c]},e.Hsluv.lchToLuv=function(r){var n=r[0],o=r[1],a=r[2],s=a/360*2*Math.PI,c=Math.cos(s)*o,i=Math.sin(s)*o;return[n,c,i]},e.Hsluv.hsluvToLch=function(r){var n=r[0],o=r[1],a=r[2];if(a>99.9999999)return[100,0,n];if(a<1e-8)return[0,0,n];var s=e.Hsluv.maxChromaForLH(a,n),c=s/100*o;return[a,c,n]},e.Hsluv.lchToHsluv=function(r){var n=r[0],o=r[1],a=r[2];if(n>99.9999999)return[a,0,100];if(n<1e-8)return[a,0,0];var s=e.Hsluv.maxChromaForLH(n,a),c=o/s*100;return[a,c,n]},e.Hsluv.hpluvToLch=function(r){var n=r[0],o=r[1],a=r[2];if(a>99.9999999)return[100,0,n];if(a<1e-8)return[0,0,n];var s=e.Hsluv.maxSafeChromaForL(a),c=s/100*o;return[a,c,n]},e.Hsluv.lchToHpluv=function(r){var n=r[0],o=r[1],a=r[2];if(n>99.9999999)return[a,0,100];if(n<1e-8)return[a,0,0];var s=e.Hsluv.maxSafeChromaForL(n),c=o/s*100;return[a,c,n]},e.Hsluv.rgbToHex=function(r){for(var n="#",o=0;o<3;){var a=o++,s=r[a],c=Math.round(s*255),i=c%16,u=(c-i)/16|0;n+=e.Hsluv.hexChars.charAt(u)+e.Hsluv.hexChars.charAt(i)}return n},e.Hsluv.hexToRgb=function(r){r=r.toLowerCase();for(var n=[],o=0;o<3;){var a=o++,s=e.Hsluv.hexChars.indexOf(r.charAt(a*2+1)),c=e.Hsluv.hexChars.indexOf(r.charAt(a*2+2)),i=s*16+c;n.push(i/255)}return n},e.Hsluv.lchToRgb=function(r){return e.Hsluv.xyzToRgb(e.Hsluv.luvToXyz(e.Hsluv.lchToLuv(r)))},e.Hsluv.rgbToLch=function(r){return e.Hsluv.luvToLch(e.Hsluv.xyzToLuv(e.Hsluv.rgbToXyz(r)))},e.Hsluv.hsluvToRgb=function(r){return e.Hsluv.lchToRgb(e.Hsluv.hsluvToLch(r))},e.Hsluv.rgbToHsluv=function(r){return e.Hsluv.lchToHsluv(e.Hsluv.rgbToLch(r))},e.Hsluv.hpluvToRgb=function(r){return e.Hsluv.lchToRgb(e.Hsluv.hpluvToLch(r))},e.Hsluv.rgbToHpluv=function(r){return e.Hsluv.lchToHpluv(e.Hsluv.rgbToLch(r))},e.Hsluv.hsluvToHex=function(r){return e.Hsluv.rgbToHex(e.Hsluv.hsluvToRgb(r))},e.Hsluv.hpluvToHex=function(r){return e.Hsluv.rgbToHex(e.Hsluv.hpluvToRgb(r))},e.Hsluv.hexToHsluv=function(r){return e.Hsluv.rgbToHsluv(e.Hsluv.hexToRgb(r))},e.Hsluv.hexToHpluv=function(r){return e.Hsluv.rgbToHpluv(e.Hsluv.hexToRgb(r))},e.Hsluv.m=[[3.240969941904521,-1.537383177570093,-.498610760293],[-.96924363628087,1.87596750150772,.041555057407175],[.055630079696993,-.20397695888897,1.056971514242878]],e.Hsluv.minv=[[.41239079926595,.35758433938387,.18048078840183],[.21263900587151,.71516867876775,.072192315360733],[.019330818715591,.11919477979462,.95053215224966]],e.Hsluv.refY=1,e.Hsluv.refU=.19783000664283,e.Hsluv.refV=.46831999493879,e.Hsluv.kappa=903.2962962,e.Hsluv.epsilon=.0088564516,e.Hsluv.hexChars="0123456789abcdef";var t={hsluvToRgb:e.Hsluv.hsluvToRgb,rgbToHsluv:e.Hsluv.rgbToHsluv,hpluvToRgb:e.Hsluv.hpluvToRgb,rgbToHpluv:e.Hsluv.rgbToHpluv,hsluvToHex:e.Hsluv.hsluvToHex,hexToHsluv:e.Hsluv.hexToHsluv,hpluvToHex:e.Hsluv.hpluvToHex,hexToHpluv:e.Hsluv.hexToHpluv,lchToHpluv:e.Hsluv.lchToHpluv,hpluvToLch:e.Hsluv.hpluvToLch,lchToHsluv:e.Hsluv.lchToHsluv,hsluvToLch:e.Hsluv.hsluvToLch,lchToLuv:e.Hsluv.lchToLuv,luvToLch:e.Hsluv.luvToLch,xyzToLuv:e.Hsluv.xyzToLuv,luvToXyz:e.Hsluv.luvToXyz,xyzToRgb:e.Hsluv.xyzToRgb,rgbToXyz:e.Hsluv.rgbToXyz,lchToRgb:e.Hsluv.lchToRgb,rgbToLch:e.Hsluv.rgbToLch};return mr=t,mr}var di=hi();const gr=pr(di);var $t={exports:{}},vr,zo;function it(){if(zo)return vr;zo=1;function e(t,r){return Object.prototype.hasOwnProperty.call(t,r)}return vr=e,vr}var _r,Fo;function yr(){if(Fo)return _r;Fo=1;var e=it(),t,r;function n(){r=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],t=!0;for(var s in{toString:null})t=!1}function o(s,c,i){var u,l=0;t==null&&n();for(u in s)if(a(c,s,u,i)===!1)break;if(t)for(var f=s.constructor,h=!!f&&s===f.prototype;(u=r[l++])&&!((u!=="constructor"||!h&&e(s,u))&&s[u]!==Object.prototype[u]&&a(c,s,u,i)===!1););}function a(s,c,i,u){return s.call(u,c[i],i,c)}return _r=o,_r}var wr,Xo;function Ko(){if(Xo)return wr;Xo=1;var e=yr();function t(r){var n=[];return e(r,function(o,a){typeof o=="function"&&n.push(a)}),n.sort()}return wr=t,wr}var kr,Do;function ut(){if(Do)return kr;Do=1;function e(t,r,n){var o=t.length;r==null?r=0:r<0?r=Math.max(o+r,0):r=Math.min(r,o),n==null?n=o:n<0?n=Math.max(o+n,0):n=Math.min(n,o);for(var a=[];r1?n(arguments,1):e(a);r(c,function(i){a[i]=t(a[i],a)})}return Rr=o,Rr}var Hr,Jo;function Q(){if(Jo)return Hr;Jo=1;var e=it(),t=yr();function r(n,o,a){t(n,function(s,c){if(e(n,c))return o.call(a,n[c],c,n)})}return Hr=r,Hr}var qr,Wo;function mi(){if(Wo)return qr;Wo=1;function e(t){return t}return qr=e,qr}var Mr,Uo;function Qo(){if(Uo)return Mr;Uo=1;function e(t){return function(r){return r[t]}}return Mr=e,Mr}var Or,es;function Nr(){if(es)return Or;es=1;var e=/^\[object (.*)\]$/,t=Object.prototype.toString,r;function n(o){return o===null?"Null":o===r?"Undefined":e.exec(t.call(o))[1]}return Or=n,Or}var Ar,ts;function Lr(){if(ts)return Ar;ts=1;var e=Nr();function t(r,n){return e(r)===n}return Ar=t,Ar}var Er,rs;function gi(){if(rs)return Er;rs=1;var e=Lr(),t=Array.isArray||function(r){return e(r,"Array")};return Er=t,Er}var Tr,ns;function os(){if(ns)return Tr;ns=1;var e=Q(),t=gi();function r(s,c){for(var i=-1,u=s.length;++is&&(s=i,a=c);return a}return rn=t,rn}var nn,Ns;function on(){if(Ns)return nn;Ns=1;var e=Q();function t(r){var n=[];return e(r,function(o,a){n.push(o)}),n}return nn=t,nn}var sn,As;function Mi(){if(As)return sn;As=1;var e=qi(),t=on();function r(n,o){return e(t(n),o)}return sn=r,sn}var an,Ls;function Es(){if(Ls)return an;Ls=1;var e=Q();function t(n,o){for(var a=0,s=arguments.length,c;++a2;if(!t(n)&&!c)throw new Error("reduce of empty object with no initial value");return e(n,function(i,u,l){c?a=o.call(s,a,i,u,l):(a=i,c=!0)}),a}return yn=r,yn}var wn,Js;function Ii(){if(Js)return wn;Js=1;var e=_s(),t=Le();function r(n,o,a){return o=t(o,a),e(n,function(s,c,i){return!o(s,c,i)},a)}return wn=r,wn}var kn,Ws;function zi(){if(Ws)return kn;Ws=1;var e=Lr();function t(r){return e(r,"Function")}return kn=t,kn}var $n,Us;function Fi(){if(Us)return $n;Us=1;var e=zi();function t(r,n){var o=r[n];if(o!==void 0)return e(o)?o.call(r):o}return $n=t,$n}var Cn,Qs;function Xi(){if(Qs)return Cn;Qs=1;var e=Is();function t(r,n,o){var a=/^(.+)\.(.+)$/.exec(n);a?e(r,a[1])[a[2]]=o:r[n]=o}return Cn=t,Cn}var xn,ea;function Ki(){if(ea)return xn;ea=1;var e=xs();function t(r,n){if(e(r,n)){for(var o=n.split("."),a=o.pop();n=o.shift();)r=r[n];return delete r[a]}else return!0}return xn=t,xn}var Rn,ta;function Hn(){return ta||(ta=1,Rn={bindAll:pi(),contains:vi(),deepFillIn:_i(),deepMatches:os(),deepMixIn:yi(),equals:ki(),every:hs(),fillIn:$i(),filter:_s(),find:Ci(),flatten:xi(),forIn:yr(),forOwn:Q(),functions:Ko(),get:$s(),has:xs(),hasOwn:it(),keys:Ri(),map:qs(),matches:Hi(),max:Mi(),merge:Ai(),min:Ei(),mixIn:Es(),namespace:Is(),omit:Pi(),pick:ji(),pluck:Bi(),reduce:Gi(),reject:Ii(),result:Fi(),set:Xi(),size:Ys(),some:jr(),unset:Ki(),values:on()}),Rn}var ra;function na(){return ra||(ra=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Hn(),n={A:{x:.44758,y:.40745},C:{x:.31006,y:.31616},D50:{x:.34567,y:.35851},D65:{x:.31272,y:.32903},D55:{x:.33243,y:.34744},D75:{x:.29903,y:.31488}},o=(0,r.map)(n,function(a){var s=100*(a.x/a.y),c=100,i=100*(1-a.x-a.y)/a.y;return[s,c,i]});t.default=o,e.exports=t.default})($t,$t.exports)),$t.exports}var Ct={exports:{}},oa;function sa(){return oa||(oa=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Math,n=r.pow,o=r.sign,a=r.abs,s={decode:function(f){return f<=.04045?f/12.92:n((f+.055)/1.055,2.4)},encode:function(f){return f<=.0031308?12.92*f:1.055*n(f,1/2.4)-.055}},c={encode:function(f){return f<.001953125?16*f:n(f,1/1.8)},decode:function(f){return f<16*.001953125?f/16:n(f,1.8)}};function i(l){return{decode:function(h){return o(h)*n(a(h),l)},encode:function(h){return o(h)*n(a(h),1/l)}}}var u={sRGB:{r:{x:.64,y:.33},g:{x:.3,y:.6},b:{x:.15,y:.06},gamma:s},"Adobe RGB":{r:{x:.64,y:.33},g:{x:.21,y:.71},b:{x:.15,y:.06},gamma:i(2.2)},"Wide Gamut RGB":{r:{x:.7347,y:.2653},g:{x:.1152,y:.8264},b:{x:.1566,y:.0177},gamma:i(563/256)},"ProPhoto RGB":{r:{x:.7347,y:.2653},g:{x:.1596,y:.8404},b:{x:.0366,y:1e-4},gamma:c}};t.default=u,e.exports=t.default})(Ct,Ct.exports)),Ct.exports}var $e={},aa;function ca(){if(aa)return $e;aa=1,Object.defineProperty($e,"__esModule",{value:!0});function e(s){return[[s[0][0],s[1][0],s[2][0]],[s[0][1],s[1][1],s[2][1]],[s[0][2],s[1][2],s[2][2]]]}function t(s){return s[0][0]*(s[2][2]*s[1][1]-s[2][1]*s[1][2])+s[1][0]*(s[2][1]*s[0][2]-s[2][2]*s[0][1])+s[2][0]*(s[1][2]*s[0][1]-s[1][1]*s[0][2])}function r(s){var c=1/t(s);return[[(s[2][2]*s[1][1]-s[2][1]*s[1][2])*c,(s[2][1]*s[0][2]-s[2][2]*s[0][1])*c,(s[1][2]*s[0][1]-s[1][1]*s[0][2])*c],[(s[2][0]*s[1][2]-s[2][2]*s[1][0])*c,(s[2][2]*s[0][0]-s[2][0]*s[0][2])*c,(s[1][0]*s[0][2]-s[1][2]*s[0][0])*c],[(s[2][1]*s[1][0]-s[2][0]*s[1][1])*c,(s[2][0]*s[0][1]-s[2][1]*s[0][0])*c,(s[1][1]*s[0][0]-s[1][0]*s[0][1])*c]]}function n(s,c){return[s[0][0]*c[0]+s[0][1]*c[1]+s[0][2]*c[2],s[1][0]*c[0]+s[1][1]*c[1]+s[1][2]*c[2],s[2][0]*c[0]+s[2][1]*c[1]+s[2][2]*c[2]]}function o(s,c){return[[s[0][0]*c[0],s[0][1]*c[1],s[0][2]*c[2]],[s[1][0]*c[0],s[1][1]*c[1],s[1][2]*c[2]],[s[2][0]*c[0],s[2][1]*c[1],s[2][2]*c[2]]]}function a(s,c){return[[s[0][0]*c[0][0]+s[0][1]*c[1][0]+s[0][2]*c[2][0],s[0][0]*c[0][1]+s[0][1]*c[1][1]+s[0][2]*c[2][1],s[0][0]*c[0][2]+s[0][1]*c[1][2]+s[0][2]*c[2][2]],[s[1][0]*c[0][0]+s[1][1]*c[1][0]+s[1][2]*c[2][0],s[1][0]*c[0][1]+s[1][1]*c[1][1]+s[1][2]*c[2][1],s[1][0]*c[0][2]+s[1][1]*c[1][2]+s[1][2]*c[2][2]],[s[2][0]*c[0][0]+s[2][1]*c[1][0]+s[2][2]*c[2][0],s[2][0]*c[0][1]+s[2][1]*c[1][1]+s[2][2]*c[2][1],s[2][0]*c[0][2]+s[2][1]*c[1][2]+s[2][2]*c[2][2]]]}return $e.transpose=e,$e.determinant=t,$e.inverse=r,$e.multiply=n,$e.scalar=o,$e.product=a,$e}var lt={},ia;function Di(){if(ia)return lt;ia=1,Object.defineProperty(lt,"__esModule",{value:!0});var e=Math,t=e.PI;function r(o){for(var a=o*180/t;a<0;)a+=360;for(;a>360;)a-=360;return a}function n(o){for(var a=t*o/180;a<0;)a+=2*t;for(;a>2*t;)a-=2*t;return a}return lt.fromRadian=r,lt.toRadian=n,lt}var ht={},ua;function Vi(){if(ua)return ht;ua=1,Object.defineProperty(ht,"__esModule",{value:!0});var e=Math,t=e.round;function r(o){return o[0]=="#"&&(o=o.slice(1)),o.length<6&&(o=o.split("").map(function(a){return a+a}).join("")),o.match(/../g).map(function(a){return parseInt(a,16)/255})}function n(o){var a=o.map(function(s){return s=t(255*s).toString(16),s.length<2&&(s="0"+s),s}).join("");return"#"+a}return ht.fromHex=r,ht.toHex=n,ht}var xt={exports:{}},fa;function Yi(){return fa||(fa=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=ca(),n=u(r),o=na(),a=i(o),s=sa(),c=i(s);function i(f){return f&&f.__esModule?f:{default:f}}function u(f){if(f&&f.__esModule)return f;var h={};if(f!=null)for(var d in f)Object.prototype.hasOwnProperty.call(f,d)&&(h[d]=f[d]);return h.default=f,h}function l(){var f=arguments.length<=0||arguments[0]===void 0?c.default.sRGB:arguments[0],h=arguments.length<=1||arguments[1]===void 0?a.default.D65:arguments[1],d=[f.r,f.g,f.b],b=n.transpose(d.map(function(x){var N=x.x/x.y,A=1,R=(1-x.x-x.y)/x.y;return[N,A,R]})),_=f.gamma,g=n.multiply(n.inverse(b),h),v=n.scalar(b,g),H=n.inverse(v);return{fromRgb:function(N){return n.multiply(v,N.map(_.decode))},toRgb:function(N){return n.multiply(H,N).map(_.encode)}}}t.default=l,e.exports=t.default})(xt,xt.exports)),xt.exports}var qn,la;function Rt(){if(la)return qn;la=1;var e=na(),t=sa(),r=ca(),n=Di(),o=Vi(),a=Yi();return qn={illuminant:e,workspace:t,matrix:r,degree:n,rgb:o,xyz:a},qn}var Zi=Rt();const Ht=pr(Zi);var be={},ha;function qt(){if(ha)return be;ha=1,Object.defineProperty(be,"__esModule",{value:!0}),be.cfs=be.distance=be.lerp=be.corLerp=void 0;var e=Hn();function t(h,d,b){return d in h?Object.defineProperty(h,d,{value:b,enumerable:!0,configurable:!0,writable:!0}):h[d]=b,h}function r(h){if(Array.isArray(h)){for(var d=0,b=Array(h.length);dg/2&&(h>d?d+=g:h+=g)}return((1-b)*h+b*d)%(g||1/0)}function u(h,d,b){var _={};for(var g in h)_[g]=i(h[g],d[g],b,g);return _}function l(h,d){var b=0;for(var _ in h)b+=a(h[_]-d[_],2);return s(b)}function f(h){return e.merge.apply(void 0,r(h.split("").map(function(d){return t({},d,!0)})))}return be.corLerp=i,be.lerp=u,be.distance=l,be.cfs=f,be}var Mt={exports:{}},da;function Ji(){return da||(da=1,(function(e,t){var r=(function(){function s(c,i){var u=[],l=!0,f=!1,h=void 0;try{for(var d=c[Symbol.iterator](),b;!(l=(b=d.next()).done)&&(u.push(b.value),!(i&&u.length===i));l=!0);}catch(_){f=!0,h=_}finally{try{!l&&d.return&&d.return()}finally{if(f)throw h}}return u}return function(c,i){if(Array.isArray(c))return c;if(Symbol.iterator in Object(c))return s(c,i);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(t,"__esModule",{value:!0});var n=Rt(),o=qt();function a(s,c){var i=arguments.length<=2||arguments[2]===void 0?1e-6:arguments[2],u=-i,l=1+i,f=Math,h=f.min,d=f.max,b=["000","fff"].map(function(R){return c.fromXyz(s.fromRgb(n.rgb.fromHex(R)))}),_=r(b,2),g=_[0],v=_[1];function H(R){var m=s.toRgb(c.toXyz(R)),p=m.map(function(y){return y>=u&&y<=l}).reduce(function(y,w){return y&&w},!0);return[p,m]}function x(R,m){for(var p=arguments.length<=2||arguments[2]===void 0?.001:arguments[2];(0,o.distance)(R,m)>p;){var y=(0,o.lerp)(R,m,.5),w=H(y),C=r(w,1),q=C[0];q?R=y:m=y}return R}function N(R){return(0,o.lerp)(g,v,R)}function A(R){return R.map(function(m){return d(u,h(l,m))})}return{contains:H,limit:x,spine:N,crop:A}}t.default=a,e.exports=t.default})(Mt,Mt.exports)),Mt.exports}var Ot={exports:{}},pe={},ba;function pa(){if(ba)return pe;ba=1;var e=(function(){function f(h,d){var b=[],_=!0,g=!1,v=void 0;try{for(var H=h[Symbol.iterator](),x;!(_=(x=H.next()).done)&&(b.push(x.value),!(d&&b.length===d));_=!0);}catch(N){g=!0,v=N}finally{try{!_&&H.return&&H.return()}finally{if(g)throw v}}return b}return function(h,d){if(Array.isArray(h))return h;if(Symbol.iterator in Object(h))return f(h,d);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(pe,"__esModule",{value:!0}),pe.toNotation=pe.fromNotation=pe.toHue=pe.fromHue=void 0;var t=qt(),r=Math,n=r.floor,o=[{s:"R",h:20.14,e:.8,H:0},{s:"Y",h:90,e:.7,H:100},{s:"G",h:164.25,e:1,H:200},{s:"B",h:237.53,e:1.2,H:300},{s:"R",h:380.14,e:.8,H:400}],a=o.map(function(f){return f.s}).slice(0,-1).join("");function s(f){f50){var _=[d,h];h=_[0],d=_[1],b=100-b}return b<1?a[h]:a[h]+b.toFixed()+a[d]}return pe.fromHue=s,pe.toHue=c,pe.fromNotation=u,pe.toNotation=l,pe}var ma;function Wi(){return ma||(ma=1,(function(e,t){var r=(function(){function S(X,D){var U=[],ce=!0,ye=!1,st=void 0;try{for(var le=X[Symbol.iterator](),qe;!(ce=(qe=le.next()).done)&&(U.push(qe.value),!(D&&U.length===D));ce=!0);}catch(he){ye=!0,st=he}finally{try{!ce&&le.return&&le.return()}finally{if(ye)throw st}}return U}return function(X,D){if(Array.isArray(X))return X;if(Symbol.iterator in Object(X))return S(X,D);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(t,"__esModule",{value:!0});var n=Rt(),o=pa(),a=i(o),s=qt(),c=Hn();function i(S){if(S&&S.__esModule)return S;var X={};if(S!=null)for(var D in S)Object.prototype.hasOwnProperty.call(S,D)&&(X[D]=S[D]);return X.default=S,X}var u=Math,l=u.pow,f=u.sqrt,h=u.exp,d=u.abs,b=u.sign,_=Math,g=_.sin,v=_.cos,H=_.atan2,x={average:{F:1,c:.69,N_c:1},dim:{F:.9,c:.59,N_c:.9},dark:{F:.8,c:.535,N_c:.8}},N=[[.7328,.4296,-.1624],[-.7036,1.6975,.0061],[.003,.0136,.9834]],A=[[.38971,.68898,-.07868],[-.22981,1.1834,.04641],[0,0,1]],R=N,m=n.matrix.inverse(N),p=n.matrix.product(A,n.matrix.inverse(N)),y=n.matrix.product(N,n.matrix.inverse(A)),w={whitePoint:n.illuminant.D65,adaptingLuminance:40,backgroundLuminance:20,surroundType:"average",discounting:!1},C=(0,s.cfs)("QJMCshH"),q=(0,s.cfs)("JCh");function M(){var S=arguments.length<=0||arguments[0]===void 0?{}:arguments[0],X=arguments.length<=1||arguments[1]===void 0?C:arguments[1];S=(0,c.merge)(w,S);var D=S.whitePoint,U=S.adaptingLuminance,ce=S.backgroundLuminance,ye=x[S.surroundType],st=ye.F,le=ye.c,qe=ye.N_c,he=D[1],wc=1/(5*U+1),Ge=.2*l(wc,4)*5*U+.1*l(1-l(wc,4),2)*l(5*U,1/3),Xt=ce/he,no=.725*l(1/Xt,.2),kc=no,$c=1.48+f(Xt),Cc=S.discounting?1:st*(1-1/3.6*h(-(U+42)/92)),$l=n.matrix.multiply(N,D),Cl=$l.map(function(I){return Cc*he/I+1-Cc}),oo=r(Cl,3),xc=oo[0],Rc=oo[1],Hc=oo[2],xl=qc(D),Rl=Mc(xl),Kt=Oc(Rl);function qc(I){var z=n.matrix.multiply(R,I),F=r(z,3),ee=F[0],W=F[1],ie=F[2];return[xc*ee,Rc*W,Hc*ie]}function Hl(I){var z=r(I,3),F=z[0],ee=z[1],W=z[2];return n.matrix.multiply(m,[F/xc,ee/Rc,W/Hc])}function Mc(I){return n.matrix.multiply(p,I).map(function(z){var F=l(Ge*d(z)/100,.42);return b(z)*400*F/(27.13+F)+.1})}function ql(I){return n.matrix.multiply(y,I.map(function(z){var F=z-.1;return b(F)*100/Ge*l(27.13*d(F)/(400-d(F)),2.380952380952381)}))}function Oc(I){var z=r(I,3),F=z[0],ee=z[1],W=z[2];return(F*2+ee+W/20-.305)*no}function so(I){return 4/le*f(I/100)*(Kt+4)*l(Ge,.25)}function Ml(I){return 6.25*l(le*I/((Kt+4)*l(Ge,.25)),2)}function Nc(I){return I*l(Ge,.25)}function Ol(I,z){return l(I/100,2)*z/l(Ge,.25)}function Nl(I){return I/l(Ge,.25)}function Al(I,z){return 100*f(I/z)}function ao(I,z){var F=z.Q,ee=z.J,W=z.M,ie=z.C,ve=z.s,Me=z.h,Oe=z.H,te={};return I.J&&(te.J=isNaN(ee)?Ml(F):ee),I.C&&(isNaN(ie)?isNaN(W)?(F=isNaN(F)?so(ee):F,te.C=Ol(ve,F)):te.C=Nl(W):te.C=z.C),I.h&&(te.h=isNaN(Me)?a.toHue(Oe):Me),I.Q&&(te.Q=isNaN(F)?so(ee):F),I.M&&(te.M=isNaN(W)?Nc(ie):W),I.s&&(isNaN(ve)?(F=isNaN(F)?so(ee):F,W=isNaN(W)?Nc(ie):W,te.s=Al(W,F)):te.s=ve),I.H&&(te.H=isNaN(Oe)?a.fromHue(Me):Oe),te}function Ll(I){var z=qc(I),F=Mc(z),ee=r(F,3),W=ee[0],ie=ee[1],ve=ee[2],Me=W-ie*12/11+ve/11,Oe=(W+ie-2*ve)/9,te=H(Oe,Me),at=n.degree.fromRadian(te),Dt=1/4*(v(te+2)+3.8),Vt=Oc(F),bt=100*l(Vt/Kt,le*$c),Pe=5e4/13*qe*kc*Dt*f(Me*Me+Oe*Oe)/(W+ie+21/20*ve),je=l(Pe,.9)*f(bt/100)*l(1.64-l(.29,Xt),.73);return ao(X,{J:bt,C:je,h:at})}function El(I){var z=ao(q,I),F=z.J,ee=z.C,W=z.h,ie=n.degree.toRadian(W),ve=l(ee/(f(F/100)*l(1.64-l(.29,Xt),.73)),10/9),Me=1/4*(v(ie+2)+3.8),Oe=Kt*l(F/100,1/le/$c),te=5e4/13*qe*kc*Me/ve,at=Oe/no+.305,Dt=at*61/20*460/1403,Vt=61/20*220/1403,bt=21/20*6300/1403-27/1403,Pe=g(ie),je=v(ie),Ie,ze;ve===0||isNaN(ve)?Ie=ze=0:d(Pe)>=d(je)?(ze=Dt/(te/Pe+Vt*je/Pe+bt),Ie=ze*je/Pe):(Ie=Dt/(te/je+Vt+bt*Pe/je),ze=Ie*Pe/je);var Tl=[20/61*at+451/1403*Ie+288/1403*ze,20/61*at-891/1403*Ie-261/1403*ze,20/61*at-220/1403*Ie-6300/1403*ze],Sl=ql(Tl),Pl=Hl(Sl);return Pl}return{fromXyz:Ll,toXyz:El,fillOut:ao}}t.default=M,e.exports=t.default})(Ot,Ot.exports)),Ot.exports}var Nt={exports:{}},ga;function Ui(){return ga||(ga=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Rt(),n=Math,o=n.sqrt,a=n.pow,s=n.exp,c=n.log,i=n.cos,u=n.sin,l=n.atan2,f={LCD:{K_L:.77,c_1:.007,c_2:.0053},SCD:{K_L:1.24,c_1:.007,c_2:.0363},UCS:{K_L:1,c_1:.007,c_2:.0228}};function h(){var d=arguments.length<=0||arguments[0]===void 0?"UCS":arguments[0],b=f[d],_=b.K_L,g=b.c_1,v=b.c_2;function H(A){var R=A.J,m=A.M,p=A.h,y=r.degree.toRadian(p),w=(1+100*g)*R/(1+g*R),C=1/v*c(1+v*m),q=C*i(y),M=C*u(y);return{J_p:w,a_p:q,b_p:M}}function x(A){var R=A.J_p,m=A.a_p,p=A.b_p,y=-R/(g*R-100*g-1),w=o(a(m,2)+a(p,2)),C=(s(v*w)-1)/v,q=l(p,m),M=r.degree.fromRadian(q);return{J:y,M:C,h:M}}function N(A,R){return o(a((A.J_p-R.J_p)/_,2)+a(A.a_p-R.a_p,2)+a(A.b_p-R.b_p,2))}return{fromCam:H,toCam:x,distance:N}}t.default=h,e.exports=t.default})(Nt,Nt.exports)),Nt.exports}var Mn,va;function Qi(){if(va)return Mn;va=1;var e=qt(),t=Ji(),r=Wi(),n=Ui(),o=pa();return Mn={gamut:t,cfs:e.cfs,lerp:e.lerp,cam:r,ucs:n,hq:o},Mn}var eu=Qi();const _a=pr(eu),ya=_a.cam({whitePoint:Ht.illuminant.D65,adaptingLuminance:40,backgroundLuminance:20,surroundType:"average",discounting:!1},_a.cfs("JCh")),wa=Ht.xyz(Ht.workspace.sRGB,Ht.illuminant.D65),ka=e=>wa.toRgb(ya.toXyz({J:e[0],C:e[1],h:e[2]})),On=e=>{const t=ya.fromXyz(wa.fromRgb(e));return[t.J,t.C,t.h]},[tu,ru]=(()=>{const e={k_l:1,c1:.007,c2:.0228},t=Math.PI,r=64/t/5,n=1/(5*r+1),o=.2*n**4*(5*r)+.1*(1-n**4)**2*(5*r)**(1/3);return[a=>{const[s,c,i]=a,u=c*o**.25;let l=(1+100*e.c1)*s/(1+e.c1*s);l/=e.k_l;const f=1/e.c2*Math.log(1+e.c2*u),h=f*Math.cos(i*(t/180)),d=f*Math.sin(i*(t/180));return[l,h,d]},a=>{const[s,c,i]=a,u=Math.sqrt(c*c+i*i),l=(Math.exp(u*e.c2)-1)/e.c2,f=(180/t*Math.atan2(i,c)+360)%360,h=l/o**.25;return[s/(1+e.c1*(100-s)),h,f]}]})(),nu=e=>ka(ru(e)),$a=e=>tu(On(e)),At=console;At.color=(e,t="")=>{const n=O(e).luminance();At.log(`%c${e} ${t}`,`background-color: ${e};padding: 5px; border-radius: 5px; color: ${n>.5?"#000":"#fff"}`)},At.ramp=(e,t=1)=>{At.log("%c ",`font-size: 1px;line-height: 16px;background: ${O.getCSSGradient(e,t)};padding: 0 0 0 200px; border-radius: 2px;`)};const Ca=(e,t,r,n,o,a,s=.1)=>{if(e===r||t===n)return!0;const c=(n-t)/(r-e),i=(a+o/c-t+c*e)/(c+1/c),u=a+o/c-i/c;return(o-i)**2+(a-u)**2{const o=(t[0]+r[0])/2,a=e(o);return Ca(...t,...r,o,a,n)?null:[o,a]},Nn=(e,t,r,n=.1)=>{const o=(r-t)/10,a=[];for(let s=t;sMath.round(e*10**t)/10**t,su=(e,t=1,r=90,n=.005)=>{const o=Nn(i=>e(i).gl()[0],0,t,n),a=Nn(i=>e(i).gl()[1],0,t,n),s=Nn(i=>e(i).gl()[2],0,t,n),c=Array.from(new Set([...o.map(i=>Lt(i[0])),...a.map(i=>Lt(i[0])),...s.map(i=>Lt(i[0]))].sort((i,u)=>i-u)));return`linear-gradient(${r}deg, ${c.map(i=>`${e(i).hex()} ${Lt(i*100)}%`).join()});`},au=e=>{e.Color.prototype.jch=function(){return On(this._rgb.slice(0,3).map(o=>o/255))},e.jch=(...o)=>new e.Color(...ka(o).map(a=>Math.floor(a*255)),"rgb"),e.Color.prototype.jab=function(){return $a(this._rgb.slice(0,3).map(o=>o/255))},e.jab=(...o)=>new e.Color(...nu(o).map(a=>Math.floor(a*255)),"rgb"),e.Color.prototype.hsluv=function(){return gr.rgbToHsluv(this._rgb.slice(0,3).map(o=>o/255))},e.hsluv=(...o)=>new e.Color(...gr.hsluvToRgb(o).map(a=>Math.floor(a*255)),"rgb");const t=e.interpolate,r={jch:On,jab:$a,hsluv:gr.rgbToHsluv},n=(o,a,s)=>(Math.abs(o-a)>360/2&&(o>a?a+=360:o+=360),((1-s)*o+s*a)%360);e.interpolate=(o,a,s=.5,c="lrgb")=>{if(r[c]){typeof o!="object"&&(o=new e.Color(o)),typeof a!="object"&&(a=new e.Color(a));const i=r[c](o.gl()),u=r[c](a.gl()),l=Number.isNaN(o.hsl()[0]),f=Number.isNaN(a.hsl()[0]);let h,d,b;switch(c){case"hsluv":i[1]<1e-10&&(i[0]=u[0]),i[1]===0&&(i[1]=u[1]),u[1]<1e-10&&(u[0]=i[0]),u[1]===0&&(u[1]=i[1]),h=n(i[0],u[0],s),d=i[1]+(u[1]-i[1])*s,b=i[2]+(u[2]-i[2])*s;break;case"jch":l&&(i[2]=u[2]),f&&(u[2]=i[2]),h=i[0]+(u[0]-i[0])*s,d=i[1]+(u[1]-i[1])*s,b=n(i[2],u[2],s);break;default:h=i[0]+(u[0]-i[0])*s,d=i[1]+(u[1]-i[1])*s,b=i[2]+(u[2]-i[2])*s}return e[c](h,d,b).alpha(o.alpha()+s*(a.alpha()-o.alpha()))}return t(o,a,s,c)},e.getCSSGradient=su};const Y={mainTRC:2.4,sRco:.2126729,sGco:.7151522,sBco:.072175,normBG:.56,normTXT:.57,revTXT:.62,revBG:.65,blkThrs:.022,blkClmp:1.414,scaleBoW:1.14,scaleWoB:1.14,loBoWoffset:.027,loWoBoffset:.027,deltaYmin:5e-4,loClip:.1};function xa(e,t,r=-1){const n=[0,1.1];if(isNaN(e)||isNaN(t)||Math.min(e,t)n[1])return 0;let o=0,a=0,s="BoW";return e=e>Y.blkThrs?e:e+Math.pow(Y.blkThrs-e,Y.blkClmp),t=t>Y.blkThrs?t:t+Math.pow(Y.blkThrs-t,Y.blkClmp),Math.abs(t-e)e?(o=(Math.pow(t,Y.normBG)-Math.pow(e,Y.normTXT))*Y.scaleBoW,a=o-.1?0:o+Y.loWoBoffset),r<0?a*100:r==0?Math.round(Math.abs(a)*100)+""+s+"":Number.isInteger(r)?(a*100).toFixed(r):0)}function Et(e=[0,0,0]){function t(r){return Math.pow(r/255,Y.mainTRC)}return Y.sRco*t(e[0])+Y.sGco*t(e[1])+Y.sBco*t(e[2])}const Ra=(e,t,r,n,o,a,s,c,i)=>{const u=1-i,l=u*u,f=l*u,d=i*i*i,b=f*e+l*3*i*r+u*3*i*i*o+d*s,_=f*t+l*3*i*n+u*3*i*i*a+d*c;return{x:b,y:_}},cu=(e,t)=>{const r=[];let n={x:+e[0],y:+e[1]};for(let o=0,a=e.length;a-2*!0>o;o+=2){const s=[{x:+e[o-2],y:+e[o-1]},{x:+e[o],y:+e[o+1]},{x:+e[o+2],y:+e[o+3]},{x:+e[o+4],y:+e[o+5]}];a-4===o?s[3]=s[2]:o||(s[0]={x:+e[o],y:+e[o+1]}),r.push([n.x,n.y,(-s[0].x+6*s[1].x+s[2].x)/6,(-s[0].y+6*s[1].y+s[2].y)/6,(s[1].x+6*s[2].x-s[3].x)/6,(s[1].y+6*s[2].y-s[3].y)/6,s[2].x,s[2].y]),n=s[2]}return r},iu=(e,t,r,n,o,a,s,c)=>{let u=e,l=t,f=0;for(let h=1;h<5;h++){const{x:d,y:b}=Ra(e,t,r,n,o,a,s,c,h/5);f+=Math.hypot(d-u,b-l),u=d,l=b}return f+=Math.hypot(s-u,c-l),f},uu=(e,t,r,n,o,a,s,c)=>{const i=Math.floor(iu(e,t,r,n,o,a,s,c)*.75),u=[];let l=0;for(let f=0;f<=i;f++){const h=f/i,d=Ra(e,t,r,n,o,a,s,c,h),b=Math.round(d.x);if(u[b]=d.y,b-l>1){const _=u[l],g=u[b];for(let v=l+1;vu[Math.round(f)]||null},Ze={CAM02:"jab",CAM02p:"jch",HEX:"hex",HSL:"hsl",HSLuv:"hsluv",HSV:"hsv",LAB:"lab",LCH:"lch",RGB:"rgb",OKLAB:"oklab",OKLCH:"oklch"};function Ee(e,t=0){const r=10**t;return Math.round(e*r)/r}function fu(e,t){let r;return e>1?r=(e-1)*t+1:e<-1?r=(e+1)*t-1:r=1,Ee(r,2)}function lu(e){return O(String(e)).jch()}function hu(e){return O(String(e)).hsluv()}function du(e,t,r){const n=[[],[],[]];if(e.forEach((a,s)=>n.forEach((c,i)=>c.push(t[s],a[i]))),r==="hcl"){const a=n[1];for(let s=1;s{const s=[];for(let c=1;c{a[i]=a[c]}),s.length=0;break}if(s.length){const c=O("#ccc").jch()[2];s.forEach(i=>{a[i]=c})}s.length=0;for(let c=a.length-1;c>0;c-=2)if(Number.isNaN(a[c]))s.push(c);else{s.forEach(i=>{a[i]=a[c]});break}for(let c=1;ccu(a).map(s=>uu(...s)));return a=>{const s=o.map(c=>{for(let i=0;in*a**e+o}function An({swatches:e,colorKeys:t,colorspace:r="LAB",shift:n=1,fullScale:o=!0,smooth:a=!1,distributeLightness:s="linear",sortColor:c=!0,asFun:i=!1}={}){const u=Ze[r];if(!u)throw new Error(`Colorspace “${r}” not supported`);if(!t)throw new Error(`Colorkeys missing: returned “${t}”`);let l;if(o)l=t.map(H=>e-e*(O(H).jch()[0]/100)).sort((H,x)=>H-x).concat(e),l.unshift(0);else{let H=t.map(A=>O(A).jch()[0]/100),x=Math.min(...H),N=Math.max(...H);l=H.map(A=>A===0||isNaN((A-x)/(N-x))?0:e-(A-x)/(N-x)*e).sort((A,R)=>A-R)}let f=bu(n,[1,e],[1,e]);if(f=l.map(H=>Math.max(0,f(H))),l=f,s==="polynomial"){const H=A=>Math.sqrt(Math.sqrt((Math.pow(A,2.25)+Math.pow(A,4))/2));l=f.map(A=>A/e).map(A=>H(A)*e)}const h=t.map((H,x)=>({colorKeys:lu(H),index:x})).sort((H,x)=>x.colorKeys[0]-H.colorKeys[0]).map(H=>t[H.index]);let d=[],b;if(o){const H=u==="lch"?O.lch(...O("#fff").lch()):"#ffffff",x=u==="lch"?O.lch(...O("#000").lch()):"#000000";d=[H,...h,x]}else c?d=h:d=t;let _;if(a){const H=d;if(d=d.map(x=>O(String(x))[u]()),u==="hcl"&&d.forEach(x=>{x[1]=Number.isNaN(x[1])?0:x[1]}),u==="jch")for(let x=0;xb(N))}else b=O.scale(d.map(H=>typeof H=="object"&&H.constructor===O.Color?H:String(H))).domain(l).mode(u);return i?b:(!a||a===!1?b.colors(e):_).filter(H=>H!=null)}function pu(e,t){const r=[],n={};return Object.keys(e).forEach(s=>{n[e[s][t]]=e[s]}),Object.keys(n).forEach(s=>r.push(n[s])),r}function mu(e){return Number.isNaN(e)?0:e}function Ln(e,t,r=!1){if(!e)throw new Error(`Cannot convert color value of “${e}”`);if(!Ze[t])throw new Error(`Cannot convert to colorspace “${t}”`);const n=Ze[t],o=O(String(e))[n]();if(t==="HSL"&&o.pop(),t==="HEX"){if(r){const u=O(String(e)).rgb();return{r:u[0],g:u[1],b:u[2]}}return o}const a={};let s=o.map(mu);s=s.map((u,l)=>{let f=Ee(u),h=l;n==="hsluv"&&(h+=2);let d=n.charAt(h);return n==="jch"&&d==="c"&&(d="C"),a[d==="j"?"J":d]=f,n in{lab:1,lch:1,jab:1,jch:1}?r||((d==="l"||d==="j")&&(f+="%"),d==="h"&&(f+="deg")):n!=="hsluv"&&(d==="s"||d==="l"||d==="v"?(a[d]=Ee(u,2),r||(f=Ee(u*100),f+="%")):d==="h"&&!r&&(f+="deg")),f});const i=`${n}(${s.join(", ")})`;return r?a:i}function Ha(e,t,r){const n=[e,t,r].map(o=>(o/=255,o<=.03928?o/12.92:((o+.055)/1.055)**2.4));return n[0]*.2126+n[1]*.7152+n[2]*.0722}function gu(e,t,r,n="wcag2"){if(r===void 0){const o=O.rgb(...t).hsluv()[2];r=Ee(o/100,2)}if(n==="wcag2"){const o=Ha(e[0],e[1],e[2]),a=Ha(t[0],t[1],t[2]),s=(o+.05)/(a+.05),c=(a+.05)/(o+.05);return r<.5?s>=1?s:-c:s<1?c:s===1?s:-s}else{if(n==="wcag3")return r<.5?xa(Et(e),Et(t))*-1:xa(Et(e),Et(t));throw new Error(`Contrast calculation method ${n} unsupported; use 'wcag2' or 'wcag3'`)}}function vu(e,t){if(!e)throw new Error("Array undefined");if(!Array.isArray(e))throw new Error("Passed object is not an array");const r=t==="wcag2"?0:1;return Math.min(...e.filter(n=>n>=r))}function _u(e,t){if(!e)throw new Error("Ratios undefined");e=e.sort((c,i)=>c-i);const r=vu(e,t),n=e.indexOf(r),o=[],a=e.slice(0,n),s=e.slice(n,e.length);for(let c=0;cc-i),o}const yu=(e,t,r,n,o)=>{const s=An({swatches:3e3,colorKeys:e._modifiedKeys,colorspace:e._colorspace,shift:1,smooth:e._smooth,asFun:!0}),c={},i=f=>{if(c[f])return c[f];const h=O(s(f)).rgb(),d=gu(h,t,r,o);return c[f]=d,d},u=f=>{const h=i(0),d=i(3e3),b=h_&&x;)x--,g/=2,Hl.push(s(u(+f)))),l};let ae=class{constructor({name:t,colorKeys:r,colorspace:n="RGB",ratios:o,smooth:a=!1,output:s="HEX",saturation:c=100}){if(this._name=t,this._colorKeys=r,this._modifiedKeys=r,this._colorspace=n,this._ratios=o,this._smooth=a,this._output=s,this._saturation=c,!this._name)throw new Error("Color missing name");if(!this._colorKeys)throw new Error("Color Keys are undefined");if(!Ze[this._colorspace])throw new Error(`Colorspace “${n}” not supported`);if(!Ze[this._output])throw new Error(`Output “${n}” not supported`);for(let i=0;i{let n=O(`${r}`).oklch(),a=n[1]*(this._saturation/100),s=O.oklch(n[0],a,n[2]),c=O.rgb(s).hex();t.push(c)}),this._modifiedKeys=t,this._generateColorScale()}_generateColorScale(){this._colorScale=An({swatches:3e3,colorKeys:this._modifiedKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth,asFun:!0})}};class qa extends ae{get backgroundColorScale(){return this._backgroundColorScale||this._generateColorScale(),this._backgroundColorScale}_generateColorScale(){ae.prototype._generateColorScale.call(this);const t=An({swatches:1e3,colorKeys:this._colorKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth});t.push(...this.colorKeys);const r=t.map((a,s)=>({value:Math.round(hu(a)[2]),index:s})),o=pu(r,"value").map(a=>t[a.index]);return o.length>=101&&(o.length=100,o.push("#ffffff")),this._backgroundColorScale=o.map(a=>Ln(a,this._output)),this._backgroundColorScale}}class wu{constructor({colors:t,backgroundColor:r,lightness:n,contrast:o=1,saturation:a=100,output:s="HEX",formula:c="wcag2"}){if(this._output=s,this._colors=t,this._lightness=n,this._saturation=a,this._formula=c,this._setBackgroundColor(r),this._setBackgroundColorValue(),this._contrast=o,!this._colors)throw new Error("No colors are defined");if(!this._backgroundColor)throw new Error("Background color is undefined");if(t.forEach(i=>{if(!i.ratios)throw new Error(`Color ${i.name}'s ratios are undefined`)}),!Ze[this._output])throw new Error(`Output “${s}” not supported`);this._saturation<100&&this._updateColorSaturation(this._saturation),this._findContrastColors(),this._findContrastColorPairs(),this._findContrastColorValues()}set formula(t){this._formula=t,this._findContrastColors()}get formula(){return this._formula}set contrast(t){this._contrast=t,this._findContrastColors()}get contrast(){return this._contrast}set lightness(t){this._lightness=t,this._setBackgroundColor(this._backgroundColor),this._findContrastColors()}get lightness(){return this._lightness}set saturation(t){this._saturation=t,this._updateColorSaturation(t),this._findContrastColors()}get saturation(){return this._saturation}set backgroundColor(t){this._setBackgroundColor(t),this._findContrastColors()}get backgroundColorValue(){return this._backgroundColorValue}get backgroundColor(){return this._backgroundColor}set colors(t){this._colors=t,this._findContrastColors()}get colors(){return this._colors}set addColor(t){this._colors.push(t),this._findContrastColors()}set removeColor(t){const r=this._colors.filter(n=>n.name!==t.name);this._colors=r,this._findContrastColors()}set updateColor(t){if(Array.isArray(t))for(let r=0;rs.name===t[r].color);n=n[0];let o=this._colors.indexOf(n);const a=this._colors.filter(s=>s.name!==t[r].color);t[r].name&&(n.name=t[r].name),t[r].colorKeys&&(n.colorKeys=t[r].colorKeys),t[r].ratios&&(n.ratios=t[r].ratios),t[r].colorspace&&(n.colorspace=t[r].colorspace),t[r].smooth&&(n.smooth=t[r].smooth),n._generateColorScale(),a.splice(o,0,n),this._colors=a}else{let r=this._colors.filter(a=>a.name===t.color);r=r[0];let n=this._colors.indexOf(r);const o=this._colors.filter(a=>a.name!==t.color);t.name&&(r.name=t.name),t.colorKeys&&(r.colorKeys=t.colorKeys),t.ratios&&(r.ratios=t.ratios),t.colorspace&&(r.colorspace=t.colorspace),t.smooth&&(r.smooth=t.smooth),r._generateColorScale(),o.splice(n,0,r),this._colors=o}this._findContrastColors()}set output(t){this._output=t,this._colors.forEach(r=>{r.output=this._output}),this._backgroundColor.output=this._output,this._findContrastColors()}get output(){return this._output}get contrastColors(){return this._contrastColors}get contrastColorPairs(){return this._contrastColorPairs}get contrastColorValues(){return this._contrastColorValues}_setBackgroundColor(t){if(typeof t=="string"){const r=new qa({name:"background",colorKeys:[t],output:"RGB"}),n=Ee(O(String(t)).hsluv()[2]);this._backgroundColor=r,this._lightness=n,this._backgroundColorValue=r[this._lightness]}else{t.output="RGB";const r=t.backgroundColorScale[this._lightness];this._backgroundColor=t,this._backgroundColorValue=r}}_setBackgroundColorValue(){this._backgroundColorValue=this._backgroundColor.backgroundColorScale[this._lightness]}_updateColorSaturation(t){this._colors.map(r=>{r.saturation=t})}_findContrastColors(){const t=O(String(this._backgroundColorValue)).rgb(),r=this._lightness/100,o={background:Ln(this._backgroundColorValue,this._output)},a=[],s=[],c={...o};return a.push(o),this._colors.map(i=>{if(i.ratios!==void 0){let u;const l=[],f={name:i.name,values:l};let h;Array.isArray(i.ratios)?h=i.ratios:Array.isArray(i.ratios)||(u=Object.keys(i.ratios),h=Object.values(i.ratios)),h=h.map(b=>fu(+b,this._contrast));const d=yu(i,t,r,h,this._formula).map(b=>Ln(b,this._output));for(let b=0;bku($u(t,e),r),En=e=>{e._clipped=!1,e._unclipped=e.slice(0);for(let t=0;t<=3;t++)t<3?((e[t]<0||e[t]>255)&&(e._clipped=!0),e[t]=Be(e[t],0,255)):t===3&&(e[t]=Be(e[t],0,1));return e},Ma={};for(let e of["Boolean","Number","String","Function","Array","Date","RegExp","Undefined","Null"])Ma[`[object ${e}]`]=e.toLowerCase();function B(e){return Ma[Object.prototype.toString.call(e)]||"object"}const T=(e,t=null)=>e.length>=3?Array.prototype.slice.call(e):B(e[0])=="object"&&t?t.split("").filter(r=>e[0][r]!==void 0).map(r=>e[0][r]):e[0].slice(0),Je=e=>{if(e.length<2)return null;const t=e.length-1;return B(e[t])=="string"?e[t].toLowerCase():null},{PI:Tt,min:Oa,max:Na}=Math,ue=e=>Math.round(e*100)/100,Tn=e=>Math.round(e*100)/100,Ce=Tt*2,Sn=Tt/3,Cu=Tt/180,xu=180/Tt;function Aa(e){return[...e.slice(0,3).reverse(),...e.slice(3)]}const E={format:{},autodetect:[]};class k{constructor(...t){const r=this;if(B(t[0])==="object"&&t[0].constructor&&t[0].constructor===this.constructor)return t[0];let n=Je(t),o=!1;if(!n){o=!0,E.sorted||(E.autodetect=E.autodetect.sort((a,s)=>s.p-a.p),E.sorted=!0);for(let a of E.autodetect)if(n=a.test(...t),n)break}if(E.format[n]){const a=E.format[n].apply(null,o?t:t.slice(0,-1));r._rgb=En(a)}else throw new Error("unknown format: "+t);r._rgb.length===3&&r._rgb.push(1)}toString(){return B(this.hex)=="function"?this.hex():`[${this._rgb.join(",")}]`}}const Ru="3.2.0",G=(...e)=>new k(...e);G.version=Ru;const We={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",laserlemon:"#ffff54",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrod:"#fafad2",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",maroon2:"#7f0000",maroon3:"#b03060",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",purple2:"#7f007f",purple3:"#a020f0",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},Hu=/^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,qu=/^#?([A-Fa-f0-9]{8}|[A-Fa-f0-9]{4})$/,La=e=>{if(e.match(Hu)){(e.length===4||e.length===7)&&(e=e.substr(1)),e.length===3&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]);const t=parseInt(e,16),r=t>>16,n=t>>8&255,o=t&255;return[r,n,o,1]}if(e.match(qu)){(e.length===5||e.length===9)&&(e=e.substr(1)),e.length===4&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]+e[3]+e[3]);const t=parseInt(e,16),r=t>>24&255,n=t>>16&255,o=t>>8&255,a=Math.round((t&255)/255*100)/100;return[r,n,o,a]}throw new Error(`unknown hex color: ${e}`)},{round:St}=Math,Ea=(...e)=>{let[t,r,n,o]=T(e,"rgba"),a=Je(e)||"auto";o===void 0&&(o=1),a==="auto"&&(a=o<1?"rgba":"rgb"),t=St(t),r=St(r),n=St(n);let c="000000"+(t<<16|r<<8|n).toString(16);c=c.substr(c.length-6);let i="0"+St(o*255).toString(16);switch(i=i.substr(i.length-2),a.toLowerCase()){case"rgba":return`#${c}${i}`;case"argb":return`#${i}${c}`;default:return`#${c}`}};k.prototype.name=function(){const e=Ea(this._rgb,"rgb");for(let t of Object.keys(We))if(We[t]===e)return t.toLowerCase();return e},E.format.named=e=>{if(e=e.toLowerCase(),We[e])return La(We[e]);throw new Error("unknown color name: "+e)},E.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&B(e)==="string"&&We[e.toLowerCase()])return"named"}}),k.prototype.alpha=function(e,t=!1){return e!==void 0&&B(e)==="number"?t?(this._rgb[3]=e,this):new k([this._rgb[0],this._rgb[1],this._rgb[2],e],"rgb"):this._rgb[3]},k.prototype.clipped=function(){return this._rgb._clipped||!1};const _e={Kn:18,labWhitePoint:"d65",Xn:.95047,Yn:1,Zn:1.08883,kE:216/24389,kKE:8,kK:24389/27,RefWhiteRGB:{X:.95047,Y:1,Z:1.08883},MtxRGB2XYZ:{m00:.4124564390896922,m01:.21267285140562253,m02:.0193338955823293,m10:.357576077643909,m11:.715152155287818,m12:.11919202588130297,m20:.18043748326639894,m21:.07217499330655958,m22:.9503040785363679},MtxXYZ2RGB:{m00:3.2404541621141045,m01:-.9692660305051868,m02:.055643430959114726,m10:-1.5371385127977166,m11:1.8760108454466942,m12:-.2040259135167538,m20:-.498531409556016,m21:.041556017530349834,m22:1.0572251882231791},As:.9414285350000001,Bs:1.040417467,Cs:1.089532651,MtxAdaptMa:{m00:.8951,m01:-.7502,m02:.0389,m10:.2664,m11:1.7135,m12:-.0685,m20:-.1614,m21:.0367,m22:1.0296},MtxAdaptMaI:{m00:.9869929054667123,m01:.43230526972339456,m02:-.008528664575177328,m10:-.14705425642099013,m11:.5183602715367776,m12:.04004282165408487,m20:.15996265166373125,m21:.0492912282128556,m22:.9684866957875502}},Mu=new Map([["a",[1.0985,.35585]],["b",[1.0985,.35585]],["c",[.98074,1.18232]],["d50",[.96422,.82521]],["d55",[.95682,.92149]],["d65",[.95047,1.08883]],["e",[1,1,1]],["f2",[.99186,.67393]],["f7",[.95041,1.08747]],["f11",[1.00962,.6435]],["icc",[.96422,.82521]]]);function xe(e){const t=Mu.get(String(e).toLowerCase());if(!t)throw new Error("unknown Lab illuminant "+e);_e.labWhitePoint=e,_e.Xn=t[0],_e.Zn=t[1]}function dt(){return _e.labWhitePoint}const Pn=(...e)=>{e=T(e,"lab");const[t,r,n]=e,[o,a,s]=Ou(t,r,n),[c,i,u]=Ta(o,a,s);return[c,i,u,e.length>3?e[3]:1]},Ou=(e,t,r)=>{const{kE:n,kK:o,kKE:a,Xn:s,Yn:c,Zn:i}=_e,u=(e+16)/116,l=.002*t+u,f=u-.005*r,h=l*l*l,d=f*f*f,b=h>n?h:(116*l-16)/o,_=e>a?Math.pow((e+16)/116,3):e/o,g=d>n?d:(116*f-16)/o,v=b*s,H=_*c,x=g*i;return[v,H,x]},jn=e=>{const t=Math.sign(e);return e=Math.abs(e),(e<=.0031308?e*12.92:1.055*Math.pow(e,1/2.4)-.055)*t},Ta=(e,t,r)=>{const{MtxAdaptMa:n,MtxAdaptMaI:o,MtxXYZ2RGB:a,RefWhiteRGB:s,Xn:c,Yn:i,Zn:u}=_e,l=c*n.m00+i*n.m10+u*n.m20,f=c*n.m01+i*n.m11+u*n.m21,h=c*n.m02+i*n.m12+u*n.m22,d=s.X*n.m00+s.Y*n.m10+s.Z*n.m20,b=s.X*n.m01+s.Y*n.m11+s.Z*n.m21,_=s.X*n.m02+s.Y*n.m12+s.Z*n.m22,g=(e*n.m00+t*n.m10+r*n.m20)*(d/l),v=(e*n.m01+t*n.m11+r*n.m21)*(b/f),H=(e*n.m02+t*n.m12+r*n.m22)*(_/h),x=g*o.m00+v*o.m10+H*o.m20,N=g*o.m01+v*o.m11+H*o.m21,A=g*o.m02+v*o.m12+H*o.m22,R=jn(x*a.m00+N*a.m10+A*a.m20),m=jn(x*a.m01+N*a.m11+A*a.m21),p=jn(x*a.m02+N*a.m12+A*a.m22);return[R*255,m*255,p*255]},Bn=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),[a,s,c]=Sa(t,r,n),[i,u,l]=Nu(a,s,c);return[i,u,l,...o.length>0&&o[0]<1?[o[0]]:[]]};function Nu(e,t,r){const{Xn:n,Yn:o,Zn:a,kE:s,kK:c}=_e,i=e/n,u=t/o,l=r/a,f=i>s?Math.pow(i,1/3):(c*i+16)/116,h=u>s?Math.pow(u,1/3):(c*u+16)/116,d=l>s?Math.pow(l,1/3):(c*l+16)/116;return[116*h-16,500*(f-h),200*(h-d)]}function Gn(e){const t=Math.sign(e);return e=Math.abs(e),(e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4))*t}const Sa=(e,t,r)=>{e=Gn(e/255),t=Gn(t/255),r=Gn(r/255);const{MtxRGB2XYZ:n,MtxAdaptMa:o,MtxAdaptMaI:a,Xn:s,Yn:c,Zn:i,As:u,Bs:l,Cs:f}=_e;let h=e*n.m00+t*n.m10+r*n.m20,d=e*n.m01+t*n.m11+r*n.m21,b=e*n.m02+t*n.m12+r*n.m22;const _=s*o.m00+c*o.m10+i*o.m20,g=s*o.m01+c*o.m11+i*o.m21,v=s*o.m02+c*o.m12+i*o.m22;let H=h*o.m00+d*o.m10+b*o.m20,x=h*o.m01+d*o.m11+b*o.m21,N=h*o.m02+d*o.m12+b*o.m22;return H*=_/u,x*=g/l,N*=v/f,h=H*a.m00+x*a.m10+N*a.m20,d=H*a.m01+x*a.m11+N*a.m21,b=H*a.m02+x*a.m12+N*a.m22,[h,d,b]};k.prototype.lab=function(){return Bn(this._rgb)},Object.assign(G,{lab:(...e)=>new k(...e,"lab"),getLabWhitePoint:dt,setLabWhitePoint:xe}),E.format.lab=Pn,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"lab"),B(e)==="array"&&e.length===3)return"lab"}}),k.prototype.darken=function(e=1){const t=this,r=t.lab();return r[0]-=_e.Kn*e,new k(r,"lab").alpha(t.alpha(),!0)},k.prototype.brighten=function(e=1){return this.darken(-e)},k.prototype.darker=k.prototype.darken,k.prototype.brighter=k.prototype.brighten,k.prototype.get=function(e){const[t,r]=e.split("."),n=this[t]();if(r){const o=t.indexOf(r)-(t.substr(0,2)==="ok"?2:0);if(o>-1)return n[o];throw new Error(`unknown channel ${r} in mode ${t}`)}else return n};const{pow:Au}=Math,Lu=1e-7,Eu=20;k.prototype.luminance=function(e,t="rgb"){if(e!==void 0&&B(e)==="number"){if(e===0)return new k([0,0,0,this._rgb[3]],"rgb");if(e===1)return new k([255,255,255,this._rgb[3]],"rgb");let r=this.luminance(),n=Eu;const o=(s,c)=>{const i=s.interpolate(c,.5,t),u=i.luminance();return Math.abs(e-u)e?o(s,i):o(i,c)},a=(r>e?o(new k([0,0,0]),this):o(this,new k([255,255,255]))).rgb();return new k([...a,this._rgb[3]])}return Tu(...this._rgb.slice(0,3))};const Tu=(e,t,r)=>(e=In(e),t=In(t),r=In(r),.2126*e+.7152*t+.0722*r),In=e=>(e/=255,e<=.03928?e/12.92:Au((e+.055)/1.055,2.4)),ne={},Ue=(e,t,r=.5,...n)=>{let o=n[0]||"lrgb";if(!ne[o]&&!n.length&&(o=Object.keys(ne)[0]),!ne[o])throw new Error(`interpolation mode ${o} is not defined`);return B(e)!=="object"&&(e=new k(e)),B(t)!=="object"&&(t=new k(t)),ne[o](e,t,r).alpha(e.alpha()+r*(t.alpha()-e.alpha()))};k.prototype.mix=k.prototype.interpolate=function(e,t=.5,...r){return Ue(this,e,t,...r)},k.prototype.premultiply=function(e=!1){const t=this._rgb,r=t[3];return e?(this._rgb=[t[0]*r,t[1]*r,t[2]*r,r],this):new k([t[0]*r,t[1]*r,t[2]*r,r],"rgb")};const{sin:Su,cos:Pu}=Math,Pa=(...e)=>{let[t,r,n]=T(e,"lch");return isNaN(n)&&(n=0),n=n*Cu,[t,Pu(n)*r,Su(n)*r]},zn=(...e)=>{e=T(e,"lch");const[t,r,n]=e,[o,a,s]=Pa(t,r,n),[c,i,u]=Pn(o,a,s);return[c,i,u,e.length>3?e[3]:1]},ju=(...e)=>{const t=Aa(T(e,"hcl"));return zn(...t)},{sqrt:Bu,atan2:Gu,round:Iu}=Math,ja=(...e)=>{const[t,r,n]=T(e,"lab"),o=Bu(r*r+n*n);let a=(Gu(n,r)*xu+360)%360;return Iu(o*1e4)===0&&(a=Number.NaN),[t,o,a]},Fn=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),[a,s,c]=Bn(t,r,n),[i,u,l]=ja(a,s,c);return[i,u,l,...o.length>0&&o[0]<1?[o[0]]:[]]};k.prototype.lch=function(){return Fn(this._rgb)},k.prototype.hcl=function(){return Aa(Fn(this._rgb))},Object.assign(G,{lch:(...e)=>new k(...e,"lch"),hcl:(...e)=>new k(...e,"hcl")}),E.format.lch=zn,E.format.hcl=ju,["lch","hcl"].forEach(e=>E.autodetect.push({p:2,test:(...t)=>{if(t=T(t,e),B(t)==="array"&&t.length===3)return e}})),k.prototype.saturate=function(e=1){const t=this,r=t.lch();return r[1]+=_e.Kn*e,r[1]<0&&(r[1]=0),new k(r,"lch").alpha(t.alpha(),!0)},k.prototype.desaturate=function(e=1){return this.saturate(-e)},k.prototype.set=function(e,t,r=!1){const[n,o]=e.split("."),a=this[n]();if(o){const s=n.indexOf(o)-(n.substr(0,2)==="ok"?2:0);if(s>-1){if(B(t)=="string")switch(t.charAt(0)){case"+":a[s]+=+t;break;case"-":a[s]+=+t;break;case"*":a[s]*=+t.substr(1);break;case"/":a[s]/=+t.substr(1);break;default:a[s]=+t}else if(B(t)==="number")a[s]=t;else throw new Error("unsupported value for Color.set");const c=new k(a,n);return r?(this._rgb=c._rgb,this):c}throw new Error(`unknown channel ${o} in mode ${n}`)}else return a},k.prototype.tint=function(e=.5,...t){return Ue(this,"white",e,...t)},k.prototype.shade=function(e=.5,...t){return Ue(this,"black",e,...t)};const zu=(e,t,r)=>{const n=e._rgb,o=t._rgb;return new k(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"rgb")};ne.rgb=zu;const{sqrt:Xn,pow:Qe}=Math,Fu=(e,t,r)=>{const[n,o,a]=e._rgb,[s,c,i]=t._rgb;return new k(Xn(Qe(n,2)*(1-r)+Qe(s,2)*r),Xn(Qe(o,2)*(1-r)+Qe(c,2)*r),Xn(Qe(a,2)*(1-r)+Qe(i,2)*r),"rgb")};ne.lrgb=Fu;const Xu=(e,t,r)=>{const n=e.lab(),o=t.lab();return new k(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"lab")};ne.lab=Xu;const et=(e,t,r,n)=>{let o,a;n==="hsl"?(o=e.hsl(),a=t.hsl()):n==="hsv"?(o=e.hsv(),a=t.hsv()):n==="hcg"?(o=e.hcg(),a=t.hcg()):n==="hsi"?(o=e.hsi(),a=t.hsi()):n==="lch"||n==="hcl"?(n="hcl",o=e.hcl(),a=t.hcl()):n==="oklch"&&(o=e.oklch().reverse(),a=t.oklch().reverse());let s,c,i,u,l,f;(n.substr(0,1)==="h"||n==="oklch")&&([s,i,l]=o,[c,u,f]=a);let h,d,b,_;return!isNaN(s)&&!isNaN(c)?(c>s&&c-s>180?_=c-(s+360):c180?_=c+360-s:_=c-s,d=s+r*_):isNaN(s)?isNaN(c)?d=Number.NaN:(d=c,(l==1||l==0)&&n!="hsv"&&(h=u)):(d=s,(f==1||f==0)&&n!="hsv"&&(h=i)),h===void 0&&(h=i+r*(u-i)),b=l+r*(f-l),n==="oklch"?new k([b,h,d],n):new k([d,h,b],n)},Ba=(e,t,r)=>et(e,t,r,"lch");ne.lch=Ba,ne.hcl=Ba;const Ku=e=>{if(B(e)=="number"&&e>=0&&e<=16777215){const t=e>>16,r=e>>8&255,n=e&255;return[t,r,n,1]}throw new Error("unknown num color: "+e)},Du=(...e)=>{const[t,r,n]=T(e,"rgb");return(t<<16)+(r<<8)+n};k.prototype.num=function(){return Du(this._rgb)},Object.assign(G,{num:(...e)=>new k(...e,"num")}),E.format.num=Ku,E.autodetect.push({p:5,test:(...e)=>{if(e.length===1&&B(e[0])==="number"&&e[0]>=0&&e[0]<=16777215)return"num"}});const Vu=(e,t,r)=>{const n=e.num(),o=t.num();return new k(n+r*(o-n),"num")};ne.num=Vu;const{floor:Yu}=Math,Zu=(...e)=>{e=T(e,"hcg");let[t,r,n]=e,o,a,s;n=n*255;const c=r*255;if(r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const i=Yu(t),u=t-i,l=n*(1-r),f=l+c*(1-u),h=l+c*u,d=l+c;switch(i){case 0:[o,a,s]=[d,h,l];break;case 1:[o,a,s]=[f,d,l];break;case 2:[o,a,s]=[l,d,h];break;case 3:[o,a,s]=[l,f,d];break;case 4:[o,a,s]=[h,l,d];break;case 5:[o,a,s]=[d,l,f];break}}return[o,a,s,e.length>3?e[3]:1]},Ju=(...e)=>{const[t,r,n]=T(e,"rgb"),o=Oa(t,r,n),a=Na(t,r,n),s=a-o,c=s*100/255,i=o/(255-s)*100;let u;return s===0?u=Number.NaN:(t===a&&(u=(r-n)/s),r===a&&(u=2+(n-t)/s),n===a&&(u=4+(t-r)/s),u*=60,u<0&&(u+=360)),[u,c,i]};k.prototype.hcg=function(){return Ju(this._rgb)};const Wu=(...e)=>new k(...e,"hcg");G.hcg=Wu,E.format.hcg=Zu,E.autodetect.push({p:1,test:(...e)=>{if(e=T(e,"hcg"),B(e)==="array"&&e.length===3)return"hcg"}});const Uu=(e,t,r)=>et(e,t,r,"hcg");ne.hcg=Uu;const{cos:tt}=Math,Qu=(...e)=>{e=T(e,"hsi");let[t,r,n]=e,o,a,s;return isNaN(t)&&(t=0),isNaN(r)&&(r=0),t>360&&(t-=360),t<0&&(t+=360),t/=360,t<1/3?(s=(1-r)/3,o=(1+r*tt(Ce*t)/tt(Sn-Ce*t))/3,a=1-(s+o)):t<2/3?(t-=1/3,o=(1-r)/3,a=(1+r*tt(Ce*t)/tt(Sn-Ce*t))/3,s=1-(o+a)):(t-=2/3,a=(1-r)/3,s=(1+r*tt(Ce*t)/tt(Sn-Ce*t))/3,o=1-(a+s)),o=Be(n*o*3),a=Be(n*a*3),s=Be(n*s*3),[o*255,a*255,s*255,e.length>3?e[3]:1]},{min:ef,sqrt:tf,acos:rf}=Math,nf=(...e)=>{let[t,r,n]=T(e,"rgb");t/=255,r/=255,n/=255;let o;const a=ef(t,r,n),s=(t+r+n)/3,c=s>0?1-a/s:0;return c===0?o=NaN:(o=(t-r+(t-n))/2,o/=tf((t-r)*(t-r)+(t-n)*(r-n)),o=rf(o),n>r&&(o=Ce-o),o/=Ce),[o*360,c,s]};k.prototype.hsi=function(){return nf(this._rgb)};const of=(...e)=>new k(...e,"hsi");G.hsi=of,E.format.hsi=Qu,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"hsi"),B(e)==="array"&&e.length===3)return"hsi"}});const sf=(e,t,r)=>et(e,t,r,"hsi");ne.hsi=sf;const Kn=(...e)=>{e=T(e,"hsl");const[t,r,n]=e;let o,a,s;if(r===0)o=a=s=n*255;else{const c=[0,0,0],i=[0,0,0],u=n<.5?n*(1+r):n+r-n*r,l=2*n-u,f=t/360;c[0]=f+1/3,c[1]=f,c[2]=f-1/3;for(let h=0;h<3;h++)c[h]<0&&(c[h]+=1),c[h]>1&&(c[h]-=1),6*c[h]<1?i[h]=l+(u-l)*6*c[h]:2*c[h]<1?i[h]=u:3*c[h]<2?i[h]=l+(u-l)*(2/3-c[h])*6:i[h]=l;[o,a,s]=[i[0]*255,i[1]*255,i[2]*255]}return e.length>3?[o,a,s,e[3]]:[o,a,s,1]},Ga=(...e)=>{e=T(e,"rgba");let[t,r,n]=e;t/=255,r/=255,n/=255;const o=Oa(t,r,n),a=Na(t,r,n),s=(a+o)/2;let c,i;return a===o?(c=0,i=Number.NaN):c=s<.5?(a-o)/(a+o):(a-o)/(2-a-o),t==a?i=(r-n)/(a-o):r==a?i=2+(n-t)/(a-o):n==a&&(i=4+(t-r)/(a-o)),i*=60,i<0&&(i+=360),e.length>3&&e[3]!==void 0?[i,c,s,e[3]]:[i,c,s]};k.prototype.hsl=function(){return Ga(this._rgb)};const af=(...e)=>new k(...e,"hsl");G.hsl=af,E.format.hsl=Kn,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"hsl"),B(e)==="array"&&e.length===3)return"hsl"}});const cf=(e,t,r)=>et(e,t,r,"hsl");ne.hsl=cf;const{floor:uf}=Math,ff=(...e)=>{e=T(e,"hsv");let[t,r,n]=e,o,a,s;if(n*=255,r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const c=uf(t),i=t-c,u=n*(1-r),l=n*(1-r*i),f=n*(1-r*(1-i));switch(c){case 0:[o,a,s]=[n,f,u];break;case 1:[o,a,s]=[l,n,u];break;case 2:[o,a,s]=[u,n,f];break;case 3:[o,a,s]=[u,l,n];break;case 4:[o,a,s]=[f,u,n];break;case 5:[o,a,s]=[n,u,l];break}}return[o,a,s,e.length>3?e[3]:1]},{min:lf,max:hf}=Math,df=(...e)=>{e=T(e,"rgb");let[t,r,n]=e;const o=lf(t,r,n),a=hf(t,r,n),s=a-o;let c,i,u;return u=a/255,a===0?(c=Number.NaN,i=0):(i=s/a,t===a&&(c=(r-n)/s),r===a&&(c=2+(n-t)/s),n===a&&(c=4+(t-r)/s),c*=60,c<0&&(c+=360)),[c,i,u]};k.prototype.hsv=function(){return df(this._rgb)};const bf=(...e)=>new k(...e,"hsv");G.hsv=bf,E.format.hsv=ff,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"hsv"),B(e)==="array"&&e.length===3)return"hsv"}});const pf=(e,t,r)=>et(e,t,r,"hsv");ne.hsv=pf;function Pt(e,t){let r=e.length;Array.isArray(e[0])||(e=[e]),Array.isArray(t[0])||(t=t.map(s=>[s]));let n=t[0].length,o=t[0].map((s,c)=>t.map(i=>i[c])),a=e.map(s=>o.map(c=>Array.isArray(s)?s.reduce((i,u,l)=>i+u*(c[l]||0),0):c.reduce((i,u)=>i+u*s,0)));return r===1&&(a=a[0]),n===1?a.map(s=>s[0]):a}const Dn=(...e)=>{e=T(e,"lab");const[t,r,n,...o]=e,[a,s,c]=mf([t,r,n]),[i,u,l]=Ta(a,s,c);return[i,u,l,...o.length>0&&o[0]<1?[o[0]]:[]]};function mf(e){var t=[[1.2268798758459243,-.5578149944602171,.2813910456659647],[-.0405757452148008,1.112286803280317,-.0717110580655164],[-.0763729366746601,-.4214933324022432,1.5869240198367816]],r=[[1,.3963377773761749,.2158037573099136],[1,-.1055613458156586,-.0638541728258133],[1,-.0894841775298119,-1.2914855480194092]],n=Pt(r,e);return Pt(t,n.map(o=>o**3))}const Vn=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),a=Sa(t,r,n);return[...gf(a),...o.length>0&&o[0]<1?[o[0]]:[]]};function gf(e){const t=[[.819022437996703,.3619062600528904,-.1288737815209879],[.0329836539323885,.9292868615863434,.0361446663506424],[.0481771893596242,.2642395317527308,.6335478284694309]],r=[[.210454268309314,.7936177747023054,-.0040720430116193],[1.9779985324311684,-2.42859224204858,.450593709617411],[.0259040424655478,.7827717124575296,-.8086757549230774]],n=Pt(t,e);return Pt(r,n.map(o=>Math.cbrt(o)))}k.prototype.oklab=function(){return Vn(this._rgb)},Object.assign(G,{oklab:(...e)=>new k(...e,"oklab")}),E.format.oklab=Dn,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"oklab"),B(e)==="array"&&e.length===3)return"oklab"}});const vf=(e,t,r)=>{const n=e.oklab(),o=t.oklab();return new k(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"oklab")};ne.oklab=vf;const _f=(e,t,r)=>et(e,t,r,"oklch");ne.oklch=_f;const{pow:Yn,sqrt:Zn,PI:Jn,cos:Ia,sin:za,atan2:yf}=Math,wf=(e,t="lrgb",r=null)=>{const n=e.length;r||(r=Array.from(new Array(n)).map(()=>1));const o=n/r.reduce(function(f,h){return f+h});if(r.forEach((f,h)=>{r[h]*=o}),e=e.map(f=>new k(f)),t==="lrgb")return kf(e,r);const a=e.shift(),s=a.get(t),c=[];let i=0,u=0;for(let f=0;f{const d=f.get(t);l+=f.alpha()*r[h+1];for(let b=0;b=360;)h-=360;s[f]=h}else s[f]=s[f]/c[f];return l/=n,new k(s,t).alpha(l>.99999?1:l,!0)},kf=(e,t)=>{const r=e.length,n=[0,0,0,0];for(let o=0;o.9999999&&(n[3]=1),new k(En(n))},{pow:$f}=Math;function jt(e){let t="rgb",r=G("#ccc"),n=0,o=[0,1],a=[0,1],s=[],c=[0,0],i=!1,u=[],l=!1,f=0,h=1,d=!1,b={},_=!0,g=1;const v=function(p){if(p=p||["#fff","#000"],p&&B(p)==="string"&&G.brewer&&G.brewer[p.toLowerCase()]&&(p=G.brewer[p.toLowerCase()]),B(p)==="array"){p.length===1&&(p=[p[0],p[0]]),p=p.slice(0);for(let y=0;y=i[w];)w++;return w-1}return 0};let x=p=>p,N=p=>p;const A=function(p,y){let w,C;if(y==null&&(y=!1),isNaN(p)||p===null)return r;y?C=p:i&&i.length>2?C=H(p)/(i.length-2):h!==f?C=(p-f)/(h-f):C=1,C=N(C),y||(C=x(C)),g!==1&&(C=$f(C,g)),C=c[0]+C*(1-c[0]-c[1]),C=Be(C,0,1);const q=Math.floor(C*1e4);if(_&&b[q])w=b[q];else{if(B(u)==="array")for(let M=0;M=S&&M===s.length-1){w=u[M];break}if(C>S&&Cb={};v(e);const m=function(p){const y=G(A(p));return l&&y[l]?y[l]():y};return m.classes=function(p){if(p!=null){if(B(p)==="array")i=p,o=[p[0],p[p.length-1]];else{const y=G.analyze(o);p===0?i=[y.min,y.max]:i=G.limits(y,"e",p)}return m}return i},m.domain=function(p){if(!arguments.length)return a;a=p.slice(0),f=p[0],h=p[p.length-1],s=[];const y=u.length;if(p.length===y&&f!==h)for(let w of Array.from(p))s.push((w-f)/(h-f));else{for(let w=0;w2){const w=p.map((q,M)=>M/(p.length-1)),C=p.map(q=>(q-f)/(h-f));C.every((q,M)=>w[M]===q)||(N=q=>{if(q<=0||q>=1)return q;let M=0;for(;q>=C[M+1];)M++;const S=(q-C[M])/(C[M+1]-C[M]);return w[M]+S*(w[M+1]-w[M])})}}return o=[f,h],m},m.mode=function(p){return arguments.length?(t=p,R(),m):t},m.range=function(p,y){return v(p),m},m.out=function(p){return l=p,m},m.spread=function(p){return arguments.length?(n=p,m):n},m.correctLightness=function(p){return p==null&&(p=!0),d=p,R(),d?x=function(y){const w=A(0,!0).lab()[0],C=A(1,!0).lab()[0],q=w>C;let M=A(y,!0).lab()[0];const S=w+(C-w)*y;let X=M-S,D=0,U=1,ce=20;for(;Math.abs(X)>.01&&ce-- >0;)(function(){return q&&(X*=-1),X<0?(D=y,y+=(U-y)*.5):(U=y,y+=(D-y)*.5),M=A(y,!0).lab()[0],X=M-S})();return y}:x=y=>y,m},m.padding=function(p){return p!=null?(B(p)==="number"&&(p=[p,p]),c=p,m):c},m.colors=function(p,y){arguments.length<2&&(y="hex");let w=[];if(arguments.length===0)w=u.slice(0);else if(p===1)w=[m(.5)];else if(p>1){const C=o[0],q=o[1]-C;w=Cf(0,p).map(M=>m(C+M/(p-1)*q))}else{e=[];let C=[];if(i&&i.length>2)for(let q=1,M=i.length,S=1<=M;S?qM;S?q++:q--)C.push((i[q-1]+i[q])*.5);else C=o;w=C.map(q=>m(q))}return G[y]&&(w=w.map(C=>C[y]())),w},m.cache=function(p){return p!=null?(_=p,m):_},m.gamma=function(p){return p!=null?(g=p,m):g},m.nodata=function(p){return p!=null?(r=G(p),m):r},m}function Cf(e,t,r){let n=[],o=ea;o?s++:s--)n.push(s);return n}const xf=function(e){let t=[1,1];for(let r=1;rnew k(a)),e.length===2)[r,n]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>r[c]+a*(n[c]-r[c]));return new k(s,"lab")};else if(e.length===3)[r,n,o]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>(1-a)*(1-a)*r[c]+2*(1-a)*a*n[c]+a*a*o[c]);return new k(s,"lab")};else if(e.length===4){let a;[r,n,o,a]=e.map(s=>s.lab()),t=function(s){const c=[0,1,2].map(i=>(1-s)*(1-s)*(1-s)*r[i]+3*(1-s)*(1-s)*s*n[i]+3*(1-s)*s*s*o[i]+s*s*s*a[i]);return new k(c,"lab")}}else if(e.length>=5){let a,s,c;a=e.map(i=>i.lab()),c=e.length-1,s=xf(c),t=function(i){const u=1-i,l=[0,1,2].map(f=>a.reduce((h,d,b)=>h+s[b]*u**(c-b)*i**b*d[f],0));return new k(l,"lab")}}else throw new RangeError("No point in running bezier with only one color.");return t},Hf=e=>{const t=Rf(e);return t.scale=()=>jt(t),t},{round:Fa}=Math;k.prototype.rgb=function(e=!0){return e===!1?this._rgb.slice(0,3):this._rgb.slice(0,3).map(Fa)},k.prototype.rgba=function(e=!0){return this._rgb.slice(0,4).map((t,r)=>r<3?e===!1?t:Fa(t):t)},Object.assign(G,{rgb:(...e)=>new k(...e,"rgb")}),E.format.rgb=(...e)=>{const t=T(e,"rgba");return t[3]===void 0&&(t[3]=1),t},E.autodetect.push({p:3,test:(...e)=>{if(e=T(e,"rgba"),B(e)==="array"&&(e.length===3||e.length===4&&B(e[3])=="number"&&e[3]>=0&&e[3]<=1))return"rgb"}});const me=(e,t,r)=>{if(!me[r])throw new Error("unknown blend mode "+r);return me[r](e,t)},Te=e=>(t,r)=>{const n=G(r).rgb(),o=G(t).rgb();return G.rgb(e(n,o))},Se=e=>(t,r)=>{const n=[];return n[0]=e(t[0],r[0]),n[1]=e(t[1],r[1]),n[2]=e(t[2],r[2]),n},qf=e=>e,Mf=(e,t)=>e*t/255,Of=(e,t)=>e>t?t:e,Nf=(e,t)=>e>t?e:t,Af=(e,t)=>255*(1-(1-e/255)*(1-t/255)),Lf=(e,t)=>t<128?2*e*t/255:255*(1-2*(1-e/255)*(1-t/255)),Ef=(e,t)=>255*(1-(1-t/255)/(e/255)),Tf=(e,t)=>e===255?255:(e=255*(t/255)/(1-e/255),e>255?255:e);me.normal=Te(Se(qf)),me.multiply=Te(Se(Mf)),me.screen=Te(Se(Af)),me.overlay=Te(Se(Lf)),me.darken=Te(Se(Of)),me.lighten=Te(Se(Nf)),me.dodge=Te(Se(Tf)),me.burn=Te(Se(Ef));const{pow:Sf,sin:Pf,cos:jf}=Math;function Bf(e=300,t=-1.5,r=1,n=1,o=[0,1]){let a=0,s;B(o)==="array"?s=o[1]-o[0]:(s=0,o=[o,o]);const c=function(i){const u=Ce*((e+120)/360+t*i),l=Sf(o[0]+s*i,n),h=(a!==0?r[0]+i*a:r)*l*(1-l)/2,d=jf(u),b=Pf(u),_=l+h*(-.14861*d+1.78277*b),g=l+h*(-.29227*d-.90649*b),v=l+h*(1.97294*d);return G(En([_*255,g*255,v*255,1]))};return c.start=function(i){return i==null?e:(e=i,c)},c.rotations=function(i){return i==null?t:(t=i,c)},c.gamma=function(i){return i==null?n:(n=i,c)},c.hue=function(i){return i==null?r:(r=i,B(r)==="array"?(a=r[1]-r[0],a===0&&(r=r[1])):a=0,c)},c.lightness=function(i){return i==null?o:(B(i)==="array"?(o=i,s=i[1]-i[0]):(o=[i,i],s=0),c)},c.scale=()=>G.scale(c),c.hue(r),c}const Gf="0123456789abcdef",{floor:If,random:zf}=Math,Ff=(e=zf)=>{let t="#";for(let r=0;r<6;r++)t+=Gf.charAt(If(e()*16));return new k(t,"hex")},{log:Xa,pow:Xf,floor:Kf,abs:Df}=Math;function Ka(e,t=null){const r={min:Number.MAX_VALUE,max:Number.MAX_VALUE*-1,sum:0,values:[],count:0};return B(e)==="object"&&(e=Object.values(e)),e.forEach(n=>{t&&B(n)==="object"&&(n=n[t]),n!=null&&!isNaN(n)&&(r.values.push(n),r.sum+=n,nr.max&&(r.max=n),r.count+=1)}),r.domain=[r.min,r.max],r.limits=(n,o)=>Da(r,n,o),r}function Da(e,t="equal",r=7){B(e)=="array"&&(e=Ka(e));const{min:n,max:o}=e,a=e.values.sort((c,i)=>c-i);if(r===1)return[n,o];const s=[];if(t.substr(0,1)==="c"&&(s.push(n),s.push(o)),t.substr(0,1)==="e"){s.push(n);for(let c=1;c 0");const c=Math.LOG10E*Xa(n),i=Math.LOG10E*Xa(o);s.push(n);for(let u=1;u200&&(f=!1)}const b={};for(let g=0;gg-v),s.push(_[0]);for(let g=1;g<_.length;g+=2){const v=_[g];!isNaN(v)&&s.indexOf(v)===-1&&s.push(v)}}return s}const Vf=(e,t)=>{e=new k(e),t=new k(t);const r=e.luminance(),n=t.luminance();return r>n?(r+.05)/(n+.05):(n+.05)/(r+.05)};const Va=.027,Yf=5e-4,Zf=.1,Ya=1.14,Bt=.022,Za=1.414,Jf=(e,t)=>{e=new k(e),t=new k(t),e.alpha()<1&&(e=Ue(t,e,e.alpha(),"rgb"));const r=Ja(...e.rgb()),n=Ja(...t.rgb()),o=r>=Bt?r:r+Math.pow(Bt-r,Za),a=n>=Bt?n:n+Math.pow(Bt-n,Za),s=Math.pow(a,.56)-Math.pow(o,.57),c=Math.pow(a,.65)-Math.pow(o,.62),i=Math.abs(a-o)0?i-Va:i+Va)*100};function Ja(e,t,r){return .2126729*Math.pow(e/255,2.4)+.7151522*Math.pow(t/255,2.4)+.072175*Math.pow(r/255,2.4)}const{sqrt:Re,pow:Z,min:Wf,max:Uf,atan2:Wa,abs:Ua,cos:Gt,sin:Qa,exp:Qf,PI:ec}=Math;function el(e,t,r=1,n=1,o=1){var a=function(he){return 360*he/(2*ec)},s=function(he){return 2*ec*he/360};e=new k(e),t=new k(t);const[c,i,u]=Array.from(e.lab()),[l,f,h]=Array.from(t.lab()),d=(c+l)/2,b=Re(Z(i,2)+Z(u,2)),_=Re(Z(f,2)+Z(h,2)),g=(b+_)/2,v=.5*(1-Re(Z(g,7)/(Z(g,7)+Z(25,7)))),H=i*(1+v),x=f*(1+v),N=Re(Z(H,2)+Z(u,2)),A=Re(Z(x,2)+Z(h,2)),R=(N+A)/2,m=a(Wa(u,H)),p=a(Wa(h,x)),y=m>=0?m:m+360,w=p>=0?p:p+360,C=Ua(y-w)>180?(y+w+360)/2:(y+w)/2,q=1-.17*Gt(s(C-30))+.24*Gt(s(2*C))+.32*Gt(s(3*C+6))-.2*Gt(s(4*C-63));let M=w-y;M=Ua(M)<=180?M:w<=y?M+360:M-360,M=2*Re(N*A)*Qa(s(M)/2);const S=l-c,X=A-N,D=1+.015*Z(d-50,2)/Re(20+Z(d-50,2)),U=1+.045*R,ce=1+.015*R*q,ye=30*Qf(-Z((C-275)/25,2)),le=-(2*Re(Z(R,7)/(Z(R,7)+Z(25,7))))*Qa(2*s(ye)),qe=Re(Z(S/(r*D),2)+Z(X/(n*U),2)+Z(M/(o*ce),2)+le*(X/(n*U))*(M/(o*ce)));return Uf(0,Wf(100,qe))}function tl(e,t,r="lab"){e=new k(e),t=new k(t);const n=e.get(r),o=t.get(r);let a=0;for(let s in n){const c=(n[s]||0)-(o[s]||0);a+=c*c}return Math.sqrt(a)}const rl=(...e)=>{try{return new k(...e),!0}catch{return!1}},nl={cool(){return jt([G.hsl(180,1,.9),G.hsl(250,.7,.4)])},hot(){return jt(["#000","#f00","#ff0","#fff"]).mode("rgb")}},Wn={OrRd:["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"],PuBu:["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#045a8d","#023858"],BuPu:["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#810f7c","#4d004b"],Oranges:["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"],BuGn:["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#006d2c","#00441b"],YlOrBr:["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#993404","#662506"],YlGn:["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#006837","#004529"],Reds:["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"],RdPu:["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177","#49006a"],Greens:["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"],YlGnBu:["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"],Purples:["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"],GnBu:["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#0868ac","#084081"],Greys:["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"],YlOrRd:["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#bd0026","#800026"],PuRd:["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#980043","#67001f"],Blues:["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"],PuBuGn:["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016c59","#014636"],Viridis:["#440154","#482777","#3f4a8a","#31678e","#26838f","#1f9d8a","#6cce5a","#b6de2b","#fee825"],Spectral:["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"],RdYlGn:["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"],RdBu:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"],PiYG:["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"],PRGn:["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"],RdYlBu:["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"],BrBG:["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"],RdGy:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"],PuOr:["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"],Set2:["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"],Accent:["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"],Set1:["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"],Set3:["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"],Dark2:["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"],Paired:["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"],Pastel2:["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"],Pastel1:["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"]},tc=Object.keys(Wn),rc=new Map(tc.map(e=>[e.toLowerCase(),e])),ol=typeof Proxy=="function"?new Proxy(Wn,{get(e,t){const r=t.toLowerCase();if(rc.has(r))return e[rc.get(r)]},getOwnPropertyNames(){return Object.getOwnPropertyNames(tc)}}):Wn,sl=(...e)=>{e=T(e,"cmyk");const[t,r,n,o]=e,a=e.length>4?e[4]:1;return o===1?[0,0,0,a]:[t>=1?0:255*(1-t)*(1-o),r>=1?0:255*(1-r)*(1-o),n>=1?0:255*(1-n)*(1-o),a]},{max:nc}=Math,al=(...e)=>{let[t,r,n]=T(e,"rgb");t=t/255,r=r/255,n=n/255;const o=1-nc(t,nc(r,n)),a=o<1?1/(1-o):0,s=(1-t-o)*a,c=(1-r-o)*a,i=(1-n-o)*a;return[s,c,i,o]};k.prototype.cmyk=function(){return al(this._rgb)},Object.assign(G,{cmyk:(...e)=>new k(...e,"cmyk")}),E.format.cmyk=sl,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"cmyk"),B(e)==="array"&&e.length===4)return"cmyk"}});const cl=(...e)=>{const t=T(e,"hsla");let r=Je(e)||"lsa";return t[0]=ue(t[0]||0)+"deg",t[1]=ue(t[1]*100)+"%",t[2]=ue(t[2]*100)+"%",r==="hsla"||t.length>3&&t[3]<1?(t[3]="/ "+(t.length>3?t[3]:1),r="hsla"):t.length=3,`${r.substr(0,3)}(${t.join(" ")})`},il=(...e)=>{const t=T(e,"lab");let r=Je(e)||"lab";return t[0]=ue(t[0])+"%",t[1]=ue(t[1]),t[2]=ue(t[2]),r==="laba"||t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`lab(${t.join(" ")})`},ul=(...e)=>{const t=T(e,"lch");let r=Je(e)||"lab";return t[0]=ue(t[0])+"%",t[1]=ue(t[1]),t[2]=isNaN(t[2])?"none":ue(t[2])+"deg",r==="lcha"||t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`lch(${t.join(" ")})`},fl=(...e)=>{const t=T(e,"lab");return t[0]=ue(t[0]*100)+"%",t[1]=Tn(t[1]),t[2]=Tn(t[2]),t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`oklab(${t.join(" ")})`},oc=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),[a,s,c]=Vn(t,r,n),[i,u,l]=ja(a,s,c);return[i,u,l,...o.length>0&&o[0]<1?[o[0]]:[]]},ll=(...e)=>{const t=T(e,"lch");return t[0]=ue(t[0]*100)+"%",t[1]=Tn(t[1]),t[2]=isNaN(t[2])?"none":ue(t[2])+"deg",t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`oklch(${t.join(" ")})`},{round:Un}=Math,hl=(...e)=>{const t=T(e,"rgba");let r=Je(e)||"rgb";if(r.substr(0,3)==="hsl")return cl(Ga(t),r);if(r.substr(0,3)==="lab"){const n=dt();xe("d50");const o=il(Bn(t),r);return xe(n),o}if(r.substr(0,3)==="lch"){const n=dt();xe("d50");const o=ul(Fn(t),r);return xe(n),o}return r.substr(0,5)==="oklab"?fl(Vn(t)):r.substr(0,5)==="oklch"?ll(oc(t)):(t[0]=Un(t[0]),t[1]=Un(t[1]),t[2]=Un(t[2]),(r==="rgba"||t.length>3&&t[3]<1)&&(t[3]="/ "+(t.length>3?t[3]:1),r="rgba"),`${r.substr(0,3)}(${t.slice(0,r==="rgb"?3:4).join(" ")})`)},sc=(...e)=>{e=T(e,"lch");const[t,r,n,...o]=e,[a,s,c]=Pa(t,r,n),[i,u,l]=Dn(a,s,c);return[i,u,l,...o.length>0&&o[0]<1?[o[0]]:[]]},He=/((?:-?\d+)|(?:-?\d+(?:\.\d+)?)%|none)/.source,ge=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)%?)|none)/.source,It=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)%)|none)/.source,fe=/\s*/.source,rt=/\s+/.source,Qn=/\s*,\s*/.source,zt=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)(?:deg)?)|none)/.source,nt=/\s*(?:\/\s*((?:[01]|[01]?\.\d+)|\d+(?:\.\d+)?%))?/.source,ac=new RegExp("^rgba?\\("+fe+[He,He,He].join(rt)+nt+"\\)$"),cc=new RegExp("^rgb\\("+fe+[He,He,He].join(Qn)+fe+"\\)$"),ic=new RegExp("^rgba\\("+fe+[He,He,He,ge].join(Qn)+fe+"\\)$"),uc=new RegExp("^hsla?\\("+fe+[zt,It,It].join(rt)+nt+"\\)$"),fc=new RegExp("^hsl?\\("+fe+[zt,It,It].join(Qn)+fe+"\\)$"),lc=/^hsla\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,hc=new RegExp("^lab\\("+fe+[ge,ge,ge].join(rt)+nt+"\\)$"),dc=new RegExp("^lch\\("+fe+[ge,ge,zt].join(rt)+nt+"\\)$"),bc=new RegExp("^oklab\\("+fe+[ge,ge,ge].join(rt)+nt+"\\)$"),pc=new RegExp("^oklch\\("+fe+[ge,ge,zt].join(rt)+nt+"\\)$"),{round:mc}=Math,ot=e=>e.map((t,r)=>r<=2?Be(mc(t),0,255):t),J=(e,t=0,r=100,n=!1)=>(typeof e=="string"&&e.endsWith("%")&&(e=parseFloat(e.substring(0,e.length-1))/100,n?e=t+(e+1)*.5*(r-t):e=t+e*(r-t)),+e),oe=(e,t)=>e==="none"?t:e,eo=e=>{if(e=e.toLowerCase().trim(),e==="transparent")return[0,0,0,0];let t;if(E.format.named)try{return E.format.named(e)}catch{}if((t=e.match(ac))||(t=e.match(cc))){let r=t.slice(1,4);for(let o=0;o<3;o++)r[o]=+J(oe(r[o],0),0,255);r=ot(r);const n=t[4]!==void 0?+J(t[4],0,1):1;return r[3]=n,r}if(t=e.match(ic)){const r=t.slice(1,5);for(let n=0;n<4;n++)r[n]=+J(r[n],0,255);return r}if((t=e.match(uc))||(t=e.match(fc))){const r=t.slice(1,4);r[0]=+oe(r[0].replace("deg",""),0),r[1]=+J(oe(r[1],0),0,100)*.01,r[2]=+J(oe(r[2],0),0,100)*.01;const n=ot(Kn(r)),o=t[4]!==void 0?+J(t[4],0,1):1;return n[3]=o,n}if(t=e.match(lc)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=Kn(r);for(let o=0;o<3;o++)n[o]=mc(n[o]);return n[3]=+t[4],n}if(t=e.match(hc)){const r=t.slice(1,4);r[0]=J(oe(r[0],0),0,100),r[1]=J(oe(r[1],0),-125,125,!0),r[2]=J(oe(r[2],0),-125,125,!0);const n=dt();xe("d50");const o=ot(Pn(r));xe(n);const a=t[4]!==void 0?+J(t[4],0,1):1;return o[3]=a,o}if(t=e.match(dc)){const r=t.slice(1,4);r[0]=J(r[0],0,100),r[1]=J(oe(r[1],0),0,150,!1),r[2]=+oe(r[2].replace("deg",""),0);const n=dt();xe("d50");const o=ot(zn(r));xe(n);const a=t[4]!==void 0?+J(t[4],0,1):1;return o[3]=a,o}if(t=e.match(bc)){const r=t.slice(1,4);r[0]=J(oe(r[0],0),0,1),r[1]=J(oe(r[1],0),-.4,.4,!0),r[2]=J(oe(r[2],0),-.4,.4,!0);const n=ot(Dn(r)),o=t[4]!==void 0?+J(t[4],0,1):1;return n[3]=o,n}if(t=e.match(pc)){const r=t.slice(1,4);r[0]=J(oe(r[0],0),0,1),r[1]=J(oe(r[1],0),0,.4,!1),r[2]=+oe(r[2].replace("deg",""),0);const n=ot(sc(r)),o=t[4]!==void 0?+J(t[4],0,1):1;return n[3]=o,n}};eo.test=e=>ac.test(e)||uc.test(e)||hc.test(e)||dc.test(e)||bc.test(e)||pc.test(e)||cc.test(e)||ic.test(e)||fc.test(e)||lc.test(e)||e==="transparent",k.prototype.css=function(e){return hl(this._rgb,e)};const dl=(...e)=>new k(...e,"css");G.css=dl,E.format.css=eo,E.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&B(e)==="string"&&eo.test(e))return"css"}}),E.format.gl=(...e)=>{const t=T(e,"rgba");return t[0]*=255,t[1]*=255,t[2]*=255,t};const bl=(...e)=>new k(...e,"gl");G.gl=bl,k.prototype.gl=function(){const e=this._rgb;return[e[0]/255,e[1]/255,e[2]/255,e[3]]},k.prototype.hex=function(e){return Ea(this._rgb,e)};const pl=(...e)=>new k(...e,"hex");G.hex=pl,E.format.hex=La,E.autodetect.push({p:4,test:(e,...t)=>{if(!t.length&&B(e)==="string"&&[3,4,5,6,7,8,9].indexOf(e.length)>=0)return"hex"}});const{log:Ft}=Math,gc=e=>{const t=e/100;let r,n,o;return t<66?(r=255,n=t<6?0:-155.25485562709179-.44596950469579133*(n=t-2)+104.49216199393888*Ft(n),o=t<20?0:-254.76935184120902+.8274096064007395*(o=t-10)+115.67994401066147*Ft(o)):(r=351.97690566805693+.114206453784165*(r=t-55)-40.25366309332127*Ft(r),n=325.4494125711974+.07943456536662342*(n=t-50)-28.0852963507957*Ft(n),o=255),[r,n,o,1]},{round:ml}=Math,gl=(...e)=>{const t=T(e,"rgb"),r=t[0],n=t[2];let o=1e3,a=4e4;const s=.4;let c;for(;a-o>s;){c=(a+o)*.5;const i=gc(c);i[2]/i[0]>=n/r?a=c:o=c}return ml(c)};k.prototype.temp=k.prototype.kelvin=k.prototype.temperature=function(){return gl(this._rgb)};const to=(...e)=>new k(...e,"temp");Object.assign(G,{temp:to,kelvin:to,temperature:to}),E.format.temp=E.format.kelvin=E.format.temperature=gc,k.prototype.oklch=function(){return oc(this._rgb)},Object.assign(G,{oklch:(...e)=>new k(...e,"oklch")}),E.format.oklch=sc,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"oklch"),B(e)==="array"&&e.length===3)return"oklch"}}),Object.assign(G,{analyze:Ka,average:wf,bezier:Hf,blend:me,brewer:ol,Color:k,colors:We,contrast:Vf,contrastAPCA:Jf,cubehelix:Bf,deltaE:el,distance:tl,input:E,interpolate:Ue,limits:Da,mix:Ue,random:Ff,scale:jt,scales:nl,valid:rl});function vl(e){return+`${Math.ceil(`${e}e+2`)}e-2`}const vc=e=>{const t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return[Number.parseInt(t[1],16),Number.parseInt(t[2],16),Number.parseInt(t[3],16)]},_c=(e,t,r)=>{const n=e/255,o=t/255,a=r/255,s=Math.min(n,o,a),c=Math.max(n,o,a),i=c-s;let u=0,l=0,f=0;return i===0?u=0:c===n?u=(o-a)/i%6:c===o?u=(a-n)/i+2:u=(n-o)/i+4,u=Math.round(u*60),u<0&&(u+=360),f=(c+s)/2,l=i===0?0:i/(1-Math.abs(2*f-1)),l=+(l*100).toFixed(1),f=+(f*100).toFixed(1),[u,l,Math.round(f)]},_l=(e,t,r,n)=>{const o=r/100,a=t*Math.min(o,1-o)/100,s=d=>{const b=(d+e/30)%12,_=o-a*Math.max(Math.min(b-3,9-b,1),-1);return Math.round(255*_).toString(16).padStart(2,"0").toUpperCase()},c=s(0),i=s(8),u=s(4),f=((d,b,_)=>Math.min(Math.max(d,b),_))(n,0,1),h=Math.round(f*255).toString(16).padStart(2,"0").toUpperCase();return`#${c}${i}${u}${h}`},yl=(e,t,r=1)=>{const n=vc(e),o=vc(t==="white"?"#FFFFFF":t==="black"?"#000000":t),a=n.map((u,l)=>[(u-o[l])/(255-o[l]),(u-o[l])/(0-o[l])]),s=vl(Math.max(...a.flat().filter(u=>/^-?\d+\.?\d*$/.test(u)))),c=n.map((u,l)=>Math.round((u-o[l]+o[l]*s)/s));if(c.includes(Number.NaN)){const u=_c(n[0],n[1],n[2]);return{h:u[0],s:Math.round(u[1]*r),l:u[2],a:1}}const i=_c(c[0],c[1],c[2]);return{h:i[0],s:Math.round(i[1]*r),l:i[2],a:s}},ro={backgroundColor:"gray",colorSpace:"OKLCH",colorSmoothing:!1,formula:"wcag2",output:"HEX",colors:{gray:[K(215,20,90),K(215,8,50),K(215,6,25)],red:[K(358,100,58),K(350,100,30)],orange:[K(32,100,48),K(12,100,30)],yellow:[K(50,100,50),K(25,100,20)],lime:[K(100,68,50),K(115,86,25)],green:[K(163,87,42),K(168,100,25)],cyan:[K(185,80,45),K(200,98,35)],blue:[K(212,98,46),K(222,95,25)],purple:[K(258,94,64),K(265,100,35)],fuchsia:[K(295,56,50),K(285,80,25)],pink:[K(334,90,50),K(330,91,25)]},themes:{light:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16.75],contrast:1,lightness:100,saturation:100},dark:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16],contrast:1,lightness:6,saturation:97},lightHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:100,saturation:100},darkHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:6,saturation:97}}};function K(e,t,r){return G.hsl(e,t/100,r/100).hex()}function wl(e,t){const r=e.colorSpace,n=e.colorSmoothing,o=e.themes[t].ratios,a=new qa({name:"gray",colorKeys:e.colors.gray,colorspace:r,ratios:o,smooth:n}),s=new ae({name:"blue",colorKeys:e.colors.blue,colorspace:r,ratios:o,smooth:n}),c=new ae({name:"cyan",colorKeys:e.colors.cyan,colorspace:r,ratios:o,smooth:n}),i=new ae({name:"fuchsia",colorKeys:e.colors.fuchsia,colorspace:r,ratios:o,smooth:n}),u=new ae({name:"green",colorKeys:e.colors.green,colorspace:r,ratios:o,smooth:n}),l=new ae({name:"lime",colorKeys:e.colors.lime,colorspace:r,ratios:o,smooth:n}),f=new ae({name:"orange",colorKeys:e.colors.orange,colorspace:r,ratios:o,smooth:n}),h=new ae({name:"pink",colorKeys:e.colors.pink,colorspace:r,ratios:o,smooth:n}),d=new ae({name:"purple",colorKeys:e.colors.purple,colorspace:r,ratios:o,smooth:n}),b=new ae({name:"red",colorKeys:e.colors.red,colorspace:r,ratios:o,smooth:n}),_=new ae({name:"yellow",colorKeys:e.colors.yellow,colorspace:r,ratios:o,smooth:n}),g={gray:a,red:b,orange:f,yellow:_,lime:l,green:u,cyan:c,blue:s,purple:d,fuchsia:i,pink:h};return e.colors.custom&&(g.custom=new ae({name:"custom",colorKeys:e.colors.custom,colorspace:r,ratios:o,smooth:n})),new wu({colors:Object.values(g),backgroundColor:g[e.backgroundColor],contrast:e.themes[t].contrast,lightness:e.themes[t].lightness,saturation:e.themes[t].saturation,output:e.output,formula:e.formula}).contrastColors}function yc(e){const t={};for(const r of Object.keys(e.themes))t[r]=wl(e,r);return t}function kl(e){ro.colors.custom=[e];const t=yc(ro);return Object.fromEntries(Object.entries(t).map(([r,n])=>{const o=n.find(s=>s&&s.name==="custom"),a=Object.fromEntries(o.values.map(({name:s,value:c})=>[s,c]));for(const[s,c]of Object.entries(a)){const i=yl(c,n[0].background);a[`alpha${s.charAt(0).toUpperCase()+s.slice(1)}`]=_l(i.h,i.s,i.l,i.a)}return[r,a]}))}return Fe.generateCustomColors=kl,Fe.generateThemesJson=yc,Fe.hslToHex=K,Fe.leonardoConfig=ro,Object.defineProperty(Fe,Symbol.toStringTag,{value:"Module"}),Fe})({}); +var CompoundTheme=(function($t){"use strict";const{min:oi,max:si}=Math,qt=(e,t=0,r=1)=>oi(si(t,e),r),xe=e=>{e._clipped=!1,e._unclipped=e.slice(0);for(let t=0;t<=3;t++)t<3?((e[t]<0||e[t]>255)&&(e._clipped=!0),e[t]=qt(e[t],0,255)):t===3&&(e[t]=qt(e[t],0,1));return e},fn={};for(let e of["Boolean","Number","String","Function","Array","Date","RegExp","Undefined","Null"])fn[`[object ${e}]`]=e.toLowerCase();function N(e){return fn[Object.prototype.toString.call(e)]||"object"}const E=(e,t=null)=>e.length>=3?Array.prototype.slice.call(e):N(e[0])=="object"&&t?t.split("").filter(r=>e[0][r]!==void 0).map(r=>e[0][r]):e[0].slice(0),Et=e=>{if(e.length<2)return null;const t=e.length-1;return N(e[t])=="string"?e[t].toLowerCase():null},{PI:Wt,min:hn,max:dn}=Math,et=e=>Math.round(e*100)/100,Ce=e=>Math.round(e*100)/100,ft=Wt*2,ke=Wt/3,ii=Wt/180,ai=180/Wt;function bn(e){return[...e.slice(0,3).reverse(),...e.slice(3)]}const $={format:{},autodetect:[]};let _=class{constructor(...t){const r=this;if(N(t[0])==="object"&&t[0].constructor&&t[0].constructor===this.constructor)return t[0];let n=Et(t),o=!1;if(!n){o=!0,$.sorted||($.autodetect=$.autodetect.sort((i,s)=>s.p-i.p),$.sorted=!0);for(let i of $.autodetect)if(n=i.test(...t),n)break}if($.format[n]){const i=$.format[n].apply(null,o?t:t.slice(0,-1));r._rgb=xe(i)}else throw new Error("unknown format: "+t);r._rgb.length===3&&r._rgb.push(1)}toString(){return N(this.hex)=="function"?this.hex():`[${this._rgb.join(",")}]`}};const ci="3.2.0",k=(...e)=>new _(...e);k.version=ci;const Lt={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",laserlemon:"#ffff54",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrod:"#fafad2",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",maroon2:"#7f0000",maroon3:"#b03060",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",purple2:"#7f007f",purple3:"#a020f0",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},ui=/^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,li=/^#?([A-Fa-f0-9]{8}|[A-Fa-f0-9]{4})$/,pn=e=>{if(e.match(ui)){(e.length===4||e.length===7)&&(e=e.substr(1)),e.length===3&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]);const t=parseInt(e,16),r=t>>16,n=t>>8&255,o=t&255;return[r,n,o,1]}if(e.match(li)){(e.length===5||e.length===9)&&(e=e.substr(1)),e.length===4&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]+e[3]+e[3]);const t=parseInt(e,16),r=t>>24&255,n=t>>16&255,o=t>>8&255,i=Math.round((t&255)/255*100)/100;return[r,n,o,i]}throw new Error(`unknown hex color: ${e}`)},{round:Ut}=Math,mn=(...e)=>{let[t,r,n,o]=E(e,"rgba"),i=Et(e)||"auto";o===void 0&&(o=1),i==="auto"&&(i=o<1?"rgba":"rgb"),t=Ut(t),r=Ut(r),n=Ut(n);let a="000000"+(t<<16|r<<8|n).toString(16);a=a.substr(a.length-6);let c="0"+Ut(o*255).toString(16);switch(c=c.substr(c.length-2),i.toLowerCase()){case"rgba":return`#${a}${c}`;case"argb":return`#${c}${a}`;default:return`#${a}`}};_.prototype.name=function(){const e=mn(this._rgb,"rgb");for(let t of Object.keys(Lt))if(Lt[t]===e)return t.toLowerCase();return e},$.format.named=e=>{if(e=e.toLowerCase(),Lt[e])return pn(Lt[e]);throw new Error("unknown color name: "+e)},$.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&N(e)==="string"&&Lt[e.toLowerCase()])return"named"}}),_.prototype.alpha=function(e,t=!1){return e!==void 0&&N(e)==="number"?t?(this._rgb[3]=e,this):new _([this._rgb[0],this._rgb[1],this._rgb[2],e],"rgb"):this._rgb[3]},_.prototype.clipped=function(){return this._rgb._clipped||!1};const ct={Kn:18,labWhitePoint:"d65",Xn:.95047,Yn:1,Zn:1.08883,kE:216/24389,kKE:8,kK:24389/27,RefWhiteRGB:{X:.95047,Y:1,Z:1.08883},MtxRGB2XYZ:{m00:.4124564390896922,m01:.21267285140562253,m02:.0193338955823293,m10:.357576077643909,m11:.715152155287818,m12:.11919202588130297,m20:.18043748326639894,m21:.07217499330655958,m22:.9503040785363679},MtxXYZ2RGB:{m00:3.2404541621141045,m01:-.9692660305051868,m02:.055643430959114726,m10:-1.5371385127977166,m11:1.8760108454466942,m12:-.2040259135167538,m20:-.498531409556016,m21:.041556017530349834,m22:1.0572251882231791},As:.9414285350000001,Bs:1.040417467,Cs:1.089532651,MtxAdaptMa:{m00:.8951,m01:-.7502,m02:.0389,m10:.2664,m11:1.7135,m12:-.0685,m20:-.1614,m21:.0367,m22:1.0296},MtxAdaptMaI:{m00:.9869929054667123,m01:.43230526972339456,m02:-.008528664575177328,m10:-.14705425642099013,m11:.5183602715367776,m12:.04004282165408487,m20:.15996265166373125,m21:.0492912282128556,m22:.9684866957875502}},fi=new Map([["a",[1.0985,.35585]],["b",[1.0985,.35585]],["c",[.98074,1.18232]],["d50",[.96422,.82521]],["d55",[.95682,.92149]],["d65",[.95047,1.08883]],["e",[1,1,1]],["f2",[.99186,.67393]],["f7",[.95041,1.08747]],["f11",[1.00962,.6435]],["icc",[.96422,.82521]]]);function ht(e){const t=fi.get(String(e).toLowerCase());if(!t)throw new Error("unknown Lab illuminant "+e);ct.labWhitePoint=e,ct.Xn=t[0],ct.Zn=t[1]}function Kt(){return ct.labWhitePoint}const Re=(...e)=>{e=E(e,"lab");const[t,r,n]=e,[o,i,s]=hi(t,r,n),[a,c,u]=gn(o,i,s);return[a,c,u,e.length>3?e[3]:1]},hi=(e,t,r)=>{const{kE:n,kK:o,kKE:i,Xn:s,Yn:a,Zn:c}=ct,u=(e+16)/116,f=.002*t+u,l=u-.005*r,h=f*f*f,d=l*l*l,b=h>n?h:(116*f-16)/o,g=e>i?Math.pow((e+16)/116,3):e/o,m=d>n?d:(116*l-16)/o,y=b*s,L=g*a,w=m*c;return[y,L,w]},qe=e=>{const t=Math.sign(e);return e=Math.abs(e),(e<=.0031308?e*12.92:1.055*Math.pow(e,1/2.4)-.055)*t},gn=(e,t,r)=>{const{MtxAdaptMa:n,MtxAdaptMaI:o,MtxXYZ2RGB:i,RefWhiteRGB:s,Xn:a,Yn:c,Zn:u}=ct,f=a*n.m00+c*n.m10+u*n.m20,l=a*n.m01+c*n.m11+u*n.m21,h=a*n.m02+c*n.m12+u*n.m22,d=s.X*n.m00+s.Y*n.m10+s.Z*n.m20,b=s.X*n.m01+s.Y*n.m11+s.Z*n.m21,g=s.X*n.m02+s.Y*n.m12+s.Z*n.m22,m=(e*n.m00+t*n.m10+r*n.m20)*(d/f),y=(e*n.m01+t*n.m11+r*n.m21)*(b/l),L=(e*n.m02+t*n.m12+r*n.m22)*(g/h),w=m*o.m00+y*o.m10+L*o.m20,x=m*o.m01+y*o.m11+L*o.m21,S=m*o.m02+y*o.m12+L*o.m22,R=qe(w*i.m00+x*i.m10+S*i.m20),M=qe(w*i.m01+x*i.m11+S*i.m21),p=qe(w*i.m02+x*i.m12+S*i.m22);return[R*255,M*255,p*255]},Me=(...e)=>{const[t,r,n,...o]=E(e,"rgb"),[i,s,a]=_n(t,r,n),[c,u,f]=di(i,s,a);return[c,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]};function di(e,t,r){const{Xn:n,Yn:o,Zn:i,kE:s,kK:a}=ct,c=e/n,u=t/o,f=r/i,l=c>s?Math.pow(c,1/3):(a*c+16)/116,h=u>s?Math.pow(u,1/3):(a*u+16)/116,d=f>s?Math.pow(f,1/3):(a*f+16)/116;return[116*h-16,500*(l-h),200*(h-d)]}function Oe(e){const t=Math.sign(e);return e=Math.abs(e),(e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4))*t}const _n=(e,t,r)=>{e=Oe(e/255),t=Oe(t/255),r=Oe(r/255);const{MtxRGB2XYZ:n,MtxAdaptMa:o,MtxAdaptMaI:i,Xn:s,Yn:a,Zn:c,As:u,Bs:f,Cs:l}=ct;let h=e*n.m00+t*n.m10+r*n.m20,d=e*n.m01+t*n.m11+r*n.m21,b=e*n.m02+t*n.m12+r*n.m22;const g=s*o.m00+a*o.m10+c*o.m20,m=s*o.m01+a*o.m11+c*o.m21,y=s*o.m02+a*o.m12+c*o.m22;let L=h*o.m00+d*o.m10+b*o.m20,w=h*o.m01+d*o.m11+b*o.m21,x=h*o.m02+d*o.m12+b*o.m22;return L*=g/u,w*=m/f,x*=y/l,h=L*i.m00+w*i.m10+x*i.m20,d=L*i.m01+w*i.m11+x*i.m21,b=L*i.m02+w*i.m12+x*i.m22,[h,d,b]};_.prototype.lab=function(){return Me(this._rgb)},Object.assign(k,{lab:(...e)=>new _(...e,"lab"),getLabWhitePoint:Kt,setLabWhitePoint:ht}),$.format.lab=Re,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"lab"),N(e)==="array"&&e.length===3)return"lab"}}),_.prototype.darken=function(e=1){const t=this,r=t.lab();return r[0]-=ct.Kn*e,new _(r,"lab").alpha(t.alpha(),!0)},_.prototype.brighten=function(e=1){return this.darken(-e)},_.prototype.darker=_.prototype.darken,_.prototype.brighter=_.prototype.brighten,_.prototype.get=function(e){const[t,r]=e.split("."),n=this[t]();if(r){const o=t.indexOf(r)-(t.substr(0,2)==="ok"?2:0);if(o>-1)return n[o];throw new Error(`unknown channel ${r} in mode ${t}`)}else return n};const{pow:bi}=Math,pi=1e-7,mi=20;_.prototype.luminance=function(e,t="rgb"){if(e!==void 0&&N(e)==="number"){if(e===0)return new _([0,0,0,this._rgb[3]],"rgb");if(e===1)return new _([255,255,255,this._rgb[3]],"rgb");let r=this.luminance(),n=mi;const o=(s,a)=>{const c=s.interpolate(a,.5,t),u=c.luminance();return Math.abs(e-u)e?o(s,c):o(c,a)},i=(r>e?o(new _([0,0,0]),this):o(this,new _([255,255,255]))).rgb();return new _([...i,this._rgb[3]])}return gi(...this._rgb.slice(0,3))};const gi=(e,t,r)=>(e=Ae(e),t=Ae(t),r=Ae(r),.2126*e+.7152*t+.0722*r),Ae=e=>(e/=255,e<=.03928?e/12.92:bi((e+.055)/1.055,2.4)),H={},Nt=(e,t,r=.5,...n)=>{let o=n[0]||"lrgb";if(!H[o]&&!n.length&&(o=Object.keys(H)[0]),!H[o])throw new Error(`interpolation mode ${o} is not defined`);return N(e)!=="object"&&(e=new _(e)),N(t)!=="object"&&(t=new _(t)),H[o](e,t,r).alpha(e.alpha()+r*(t.alpha()-e.alpha()))};_.prototype.mix=_.prototype.interpolate=function(e,t=.5,...r){return Nt(this,e,t,...r)},_.prototype.premultiply=function(e=!1){const t=this._rgb,r=t[3];return e?(this._rgb=[t[0]*r,t[1]*r,t[2]*r,r],this):new _([t[0]*r,t[1]*r,t[2]*r,r],"rgb")};const{sin:_i,cos:vi}=Math,vn=(...e)=>{let[t,r,n]=E(e,"lch");return isNaN(n)&&(n=0),n=n*ii,[t,vi(n)*r,_i(n)*r]},Se=(...e)=>{e=E(e,"lch");const[t,r,n]=e,[o,i,s]=vn(t,r,n),[a,c,u]=Re(o,i,s);return[a,c,u,e.length>3?e[3]:1]},yi=(...e)=>{const t=bn(E(e,"hcl"));return Se(...t)},{sqrt:wi,atan2:xi,round:Ci}=Math,yn=(...e)=>{const[t,r,n]=E(e,"lab"),o=wi(r*r+n*n);let i=(xi(n,r)*ai+360)%360;return Ci(o*1e4)===0&&(i=Number.NaN),[t,o,i]},$e=(...e)=>{const[t,r,n,...o]=E(e,"rgb"),[i,s,a]=Me(t,r,n),[c,u,f]=yn(i,s,a);return[c,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]};_.prototype.lch=function(){return $e(this._rgb)},_.prototype.hcl=function(){return bn($e(this._rgb))},Object.assign(k,{lch:(...e)=>new _(...e,"lch"),hcl:(...e)=>new _(...e,"hcl")}),$.format.lch=Se,$.format.hcl=yi,["lch","hcl"].forEach(e=>$.autodetect.push({p:2,test:(...t)=>{if(t=E(t,e),N(t)==="array"&&t.length===3)return e}})),_.prototype.saturate=function(e=1){const t=this,r=t.lch();return r[1]+=ct.Kn*e,r[1]<0&&(r[1]=0),new _(r,"lch").alpha(t.alpha(),!0)},_.prototype.desaturate=function(e=1){return this.saturate(-e)},_.prototype.set=function(e,t,r=!1){const[n,o]=e.split("."),i=this[n]();if(o){const s=n.indexOf(o)-(n.substr(0,2)==="ok"?2:0);if(s>-1){if(N(t)=="string")switch(t.charAt(0)){case"+":i[s]+=+t;break;case"-":i[s]+=+t;break;case"*":i[s]*=+t.substr(1);break;case"/":i[s]/=+t.substr(1);break;default:i[s]=+t}else if(N(t)==="number")i[s]=t;else throw new Error("unsupported value for Color.set");const a=new _(i,n);return r?(this._rgb=a._rgb,this):a}throw new Error(`unknown channel ${o} in mode ${n}`)}else return i},_.prototype.tint=function(e=.5,...t){return Nt(this,"white",e,...t)},_.prototype.shade=function(e=.5,...t){return Nt(this,"black",e,...t)};const ki=(e,t,r)=>{const n=e._rgb,o=t._rgb;return new _(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"rgb")};H.rgb=ki;const{sqrt:Ee,pow:Tt}=Math,Ri=(e,t,r)=>{const[n,o,i]=e._rgb,[s,a,c]=t._rgb;return new _(Ee(Tt(n,2)*(1-r)+Tt(s,2)*r),Ee(Tt(o,2)*(1-r)+Tt(a,2)*r),Ee(Tt(i,2)*(1-r)+Tt(c,2)*r),"rgb")};H.lrgb=Ri;const qi=(e,t,r)=>{const n=e.lab(),o=t.lab();return new _(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"lab")};H.lab=qi;const jt=(e,t,r,n)=>{let o,i;n==="hsl"?(o=e.hsl(),i=t.hsl()):n==="hsv"?(o=e.hsv(),i=t.hsv()):n==="hcg"?(o=e.hcg(),i=t.hcg()):n==="hsi"?(o=e.hsi(),i=t.hsi()):n==="lch"||n==="hcl"?(n="hcl",o=e.hcl(),i=t.hcl()):n==="oklch"&&(o=e.oklch().reverse(),i=t.oklch().reverse());let s,a,c,u,f,l;(n.substr(0,1)==="h"||n==="oklch")&&([s,c,f]=o,[a,u,l]=i);let h,d,b,g;return!isNaN(s)&&!isNaN(a)?(a>s&&a-s>180?g=a-(s+360):a180?g=a+360-s:g=a-s,d=s+r*g):isNaN(s)?isNaN(a)?d=Number.NaN:(d=a,(f==1||f==0)&&n!="hsv"&&(h=u)):(d=s,(l==1||l==0)&&n!="hsv"&&(h=c)),h===void 0&&(h=c+r*(u-c)),b=f+r*(l-f),n==="oklch"?new _([b,h,d],n):new _([d,h,b],n)},wn=(e,t,r)=>jt(e,t,r,"lch");H.lch=wn,H.hcl=wn;const Mi=e=>{if(N(e)=="number"&&e>=0&&e<=16777215){const t=e>>16,r=e>>8&255,n=e&255;return[t,r,n,1]}throw new Error("unknown num color: "+e)},Oi=(...e)=>{const[t,r,n]=E(e,"rgb");return(t<<16)+(r<<8)+n};_.prototype.num=function(){return Oi(this._rgb)},Object.assign(k,{num:(...e)=>new _(...e,"num")}),$.format.num=Mi,$.autodetect.push({p:5,test:(...e)=>{if(e.length===1&&N(e[0])==="number"&&e[0]>=0&&e[0]<=16777215)return"num"}});const Ai=(e,t,r)=>{const n=e.num(),o=t.num();return new _(n+r*(o-n),"num")};H.num=Ai;const{floor:Si}=Math,$i=(...e)=>{e=E(e,"hcg");let[t,r,n]=e,o,i,s;n=n*255;const a=r*255;if(r===0)o=i=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const c=Si(t),u=t-c,f=n*(1-r),l=f+a*(1-u),h=f+a*u,d=f+a;switch(c){case 0:[o,i,s]=[d,h,f];break;case 1:[o,i,s]=[l,d,f];break;case 2:[o,i,s]=[f,d,h];break;case 3:[o,i,s]=[f,l,d];break;case 4:[o,i,s]=[h,f,d];break;case 5:[o,i,s]=[d,f,l];break}}return[o,i,s,e.length>3?e[3]:1]},Ei=(...e)=>{const[t,r,n]=E(e,"rgb"),o=hn(t,r,n),i=dn(t,r,n),s=i-o,a=s*100/255,c=o/(255-s)*100;let u;return s===0?u=Number.NaN:(t===i&&(u=(r-n)/s),r===i&&(u=2+(n-t)/s),n===i&&(u=4+(t-r)/s),u*=60,u<0&&(u+=360)),[u,a,c]};_.prototype.hcg=function(){return Ei(this._rgb)};const Li=(...e)=>new _(...e,"hcg");k.hcg=Li,$.format.hcg=$i,$.autodetect.push({p:1,test:(...e)=>{if(e=E(e,"hcg"),N(e)==="array"&&e.length===3)return"hcg"}});const Ni=(e,t,r)=>jt(e,t,r,"hcg");H.hcg=Ni;const{cos:Pt}=Math,Ti=(...e)=>{e=E(e,"hsi");let[t,r,n]=e,o,i,s;return isNaN(t)&&(t=0),isNaN(r)&&(r=0),t>360&&(t-=360),t<0&&(t+=360),t/=360,t<1/3?(s=(1-r)/3,o=(1+r*Pt(ft*t)/Pt(ke-ft*t))/3,i=1-(s+o)):t<2/3?(t-=1/3,o=(1-r)/3,i=(1+r*Pt(ft*t)/Pt(ke-ft*t))/3,s=1-(o+i)):(t-=2/3,i=(1-r)/3,s=(1+r*Pt(ft*t)/Pt(ke-ft*t))/3,o=1-(i+s)),o=qt(n*o*3),i=qt(n*i*3),s=qt(n*s*3),[o*255,i*255,s*255,e.length>3?e[3]:1]},{min:ji,sqrt:Pi,acos:zi}=Math,Bi=(...e)=>{let[t,r,n]=E(e,"rgb");t/=255,r/=255,n/=255;let o;const i=ji(t,r,n),s=(t+r+n)/3,a=s>0?1-i/s:0;return a===0?o=NaN:(o=(t-r+(t-n))/2,o/=Pi((t-r)*(t-r)+(t-n)*(r-n)),o=zi(o),n>r&&(o=ft-o),o/=ft),[o*360,a,s]};_.prototype.hsi=function(){return Bi(this._rgb)};const Ii=(...e)=>new _(...e,"hsi");k.hsi=Ii,$.format.hsi=Ti,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"hsi"),N(e)==="array"&&e.length===3)return"hsi"}});const Gi=(e,t,r)=>jt(e,t,r,"hsi");H.hsi=Gi;const Le=(...e)=>{e=E(e,"hsl");const[t,r,n]=e;let o,i,s;if(r===0)o=i=s=n*255;else{const a=[0,0,0],c=[0,0,0],u=n<.5?n*(1+r):n+r-n*r,f=2*n-u,l=t/360;a[0]=l+1/3,a[1]=l,a[2]=l-1/3;for(let h=0;h<3;h++)a[h]<0&&(a[h]+=1),a[h]>1&&(a[h]-=1),6*a[h]<1?c[h]=f+(u-f)*6*a[h]:2*a[h]<1?c[h]=u:3*a[h]<2?c[h]=f+(u-f)*(2/3-a[h])*6:c[h]=f;[o,i,s]=[c[0]*255,c[1]*255,c[2]*255]}return e.length>3?[o,i,s,e[3]]:[o,i,s,1]},xn=(...e)=>{e=E(e,"rgba");let[t,r,n]=e;t/=255,r/=255,n/=255;const o=hn(t,r,n),i=dn(t,r,n),s=(i+o)/2;let a,c;return i===o?(a=0,c=Number.NaN):a=s<.5?(i-o)/(i+o):(i-o)/(2-i-o),t==i?c=(r-n)/(i-o):r==i?c=2+(n-t)/(i-o):n==i&&(c=4+(t-r)/(i-o)),c*=60,c<0&&(c+=360),e.length>3&&e[3]!==void 0?[c,a,s,e[3]]:[c,a,s]};_.prototype.hsl=function(){return xn(this._rgb)};const Fi=(...e)=>new _(...e,"hsl");k.hsl=Fi,$.format.hsl=Le,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"hsl"),N(e)==="array"&&e.length===3)return"hsl"}});const Ki=(e,t,r)=>jt(e,t,r,"hsl");H.hsl=Ki;const{floor:Xi}=Math,Di=(...e)=>{e=E(e,"hsv");let[t,r,n]=e,o,i,s;if(n*=255,r===0)o=i=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const a=Xi(t),c=t-a,u=n*(1-r),f=n*(1-r*c),l=n*(1-r*(1-c));switch(a){case 0:[o,i,s]=[n,l,u];break;case 1:[o,i,s]=[f,n,u];break;case 2:[o,i,s]=[u,n,l];break;case 3:[o,i,s]=[u,f,n];break;case 4:[o,i,s]=[l,u,n];break;case 5:[o,i,s]=[n,u,f];break}}return[o,i,s,e.length>3?e[3]:1]},{min:Vi,max:Yi}=Math,Zi=(...e)=>{e=E(e,"rgb");let[t,r,n]=e;const o=Vi(t,r,n),i=Yi(t,r,n),s=i-o;let a,c,u;return u=i/255,i===0?(a=Number.NaN,c=0):(c=s/i,t===i&&(a=(r-n)/s),r===i&&(a=2+(n-t)/s),n===i&&(a=4+(t-r)/s),a*=60,a<0&&(a+=360)),[a,c,u]};_.prototype.hsv=function(){return Zi(this._rgb)};const Ji=(...e)=>new _(...e,"hsv");k.hsv=Ji,$.format.hsv=Di,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"hsv"),N(e)==="array"&&e.length===3)return"hsv"}});const Hi=(e,t,r)=>jt(e,t,r,"hsv");H.hsv=Hi;function Qt(e,t){let r=e.length;Array.isArray(e[0])||(e=[e]),Array.isArray(t[0])||(t=t.map(s=>[s]));let n=t[0].length,o=t[0].map((s,a)=>t.map(c=>c[a])),i=e.map(s=>o.map(a=>Array.isArray(s)?s.reduce((c,u,f)=>c+u*(a[f]||0),0):a.reduce((c,u)=>c+u*s,0)));return r===1&&(i=i[0]),n===1?i.map(s=>s[0]):i}const Ne=(...e)=>{e=E(e,"lab");const[t,r,n,...o]=e,[i,s,a]=Wi([t,r,n]),[c,u,f]=gn(i,s,a);return[c,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]};function Wi(e){var t=[[1.2268798758459243,-.5578149944602171,.2813910456659647],[-.0405757452148008,1.112286803280317,-.0717110580655164],[-.0763729366746601,-.4214933324022432,1.5869240198367816]],r=[[1,.3963377773761749,.2158037573099136],[1,-.1055613458156586,-.0638541728258133],[1,-.0894841775298119,-1.2914855480194092]],n=Qt(r,e);return Qt(t,n.map(o=>o**3))}const Te=(...e)=>{const[t,r,n,...o]=E(e,"rgb"),i=_n(t,r,n);return[...Ui(i),...o.length>0&&o[0]<1?[o[0]]:[]]};function Ui(e){const t=[[.819022437996703,.3619062600528904,-.1288737815209879],[.0329836539323885,.9292868615863434,.0361446663506424],[.0481771893596242,.2642395317527308,.6335478284694309]],r=[[.210454268309314,.7936177747023054,-.0040720430116193],[1.9779985324311684,-2.42859224204858,.450593709617411],[.0259040424655478,.7827717124575296,-.8086757549230774]],n=Qt(t,e);return Qt(r,n.map(o=>Math.cbrt(o)))}_.prototype.oklab=function(){return Te(this._rgb)},Object.assign(k,{oklab:(...e)=>new _(...e,"oklab")}),$.format.oklab=Ne,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"oklab"),N(e)==="array"&&e.length===3)return"oklab"}});const Qi=(e,t,r)=>{const n=e.oklab(),o=t.oklab();return new _(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"oklab")};H.oklab=Qi;const ta=(e,t,r)=>jt(e,t,r,"oklch");H.oklch=ta;const{pow:je,sqrt:Pe,PI:ze,cos:Cn,sin:kn,atan2:ea}=Math,ra=(e,t="lrgb",r=null)=>{const n=e.length;r||(r=Array.from(new Array(n)).map(()=>1));const o=n/r.reduce(function(l,h){return l+h});if(r.forEach((l,h)=>{r[h]*=o}),e=e.map(l=>new _(l)),t==="lrgb")return na(e,r);const i=e.shift(),s=i.get(t),a=[];let c=0,u=0;for(let l=0;l{const d=l.get(t);f+=l.alpha()*r[h+1];for(let b=0;b=360;)h-=360;s[l]=h}else s[l]=s[l]/a[l];return f/=n,new _(s,t).alpha(f>.99999?1:f,!0)},na=(e,t)=>{const r=e.length,n=[0,0,0,0];for(let o=0;o.9999999&&(n[3]=1),new _(xe(n))},{pow:oa}=Math;function te(e){let t="rgb",r=k("#ccc"),n=0,o=[0,1],i=[0,1],s=[],a=[0,0],c=!1,u=[],f=!1,l=0,h=1,d=!1,b={},g=!0,m=1;const y=function(p){if(p=p||["#fff","#000"],p&&N(p)==="string"&&k.brewer&&k.brewer[p.toLowerCase()]&&(p=k.brewer[p.toLowerCase()]),N(p)==="array"){p.length===1&&(p=[p[0],p[0]]),p=p.slice(0);for(let C=0;C=c[O];)O++;return O-1}return 0};let w=p=>p,x=p=>p;const S=function(p,C){let O,q;if(C==null&&(C=!1),isNaN(p)||p===null)return r;C?q=p:c&&c.length>2?q=L(p)/(c.length-2):h!==l?q=(p-l)/(h-l):q=1,q=x(q),C||(q=w(q)),m!==1&&(q=oa(q,m)),q=a[0]+q*(1-a[0]-a[1]),q=qt(q,0,1);const T=Math.floor(q*1e4);if(g&&b[T])O=b[T];else{if(N(u)==="array")for(let A=0;A=P&&A===s.length-1){O=u[A];break}if(q>P&&qb={};y(e);const M=function(p){const C=k(S(p));return f&&C[f]?C[f]():C};return M.classes=function(p){if(p!=null){if(N(p)==="array")c=p,o=[p[0],p[p.length-1]];else{const C=k.analyze(o);p===0?c=[C.min,C.max]:c=k.limits(C,"e",p)}return M}return c},M.domain=function(p){if(!arguments.length)return i;i=p.slice(0),l=p[0],h=p[p.length-1],s=[];const C=u.length;if(p.length===C&&l!==h)for(let O of Array.from(p))s.push((O-l)/(h-l));else{for(let O=0;O2){const O=p.map((T,A)=>A/(p.length-1)),q=p.map(T=>(T-l)/(h-l));q.every((T,A)=>O[A]===T)||(x=T=>{if(T<=0||T>=1)return T;let A=0;for(;T>=q[A+1];)A++;const P=(T-q[A])/(q[A+1]-q[A]);return O[A]+P*(O[A+1]-O[A])})}}return o=[l,h],M},M.mode=function(p){return arguments.length?(t=p,R(),M):t},M.range=function(p,C){return y(p),M},M.out=function(p){return f=p,M},M.spread=function(p){return arguments.length?(n=p,M):n},M.correctLightness=function(p){return p==null&&(p=!0),d=p,R(),d?w=function(C){const O=S(0,!0).lab()[0],q=S(1,!0).lab()[0],T=O>q;let A=S(C,!0).lab()[0];const P=O+(q-O)*C;let G=A-P,Y=0,U=1,ut=20;for(;Math.abs(G)>.01&&ut-- >0;)(function(){return T&&(G*=-1),G<0?(Y=C,C+=(U-C)*.5):(U=C,C+=(Y-C)*.5),A=S(C,!0).lab()[0],G=A-P})();return C}:w=C=>C,M},M.padding=function(p){return p!=null?(N(p)==="number"&&(p=[p,p]),a=p,M):a},M.colors=function(p,C){arguments.length<2&&(C="hex");let O=[];if(arguments.length===0)O=u.slice(0);else if(p===1)O=[M(.5)];else if(p>1){const q=o[0],T=o[1]-q;O=sa(0,p).map(A=>M(q+A/(p-1)*T))}else{e=[];let q=[];if(c&&c.length>2)for(let T=1,A=c.length,P=1<=A;P?TA;P?T++:T--)q.push((c[T-1]+c[T])*.5);else q=o;O=q.map(T=>M(T))}return k[C]&&(O=O.map(q=>q[C]())),O},M.cache=function(p){return p!=null?(g=p,M):g},M.gamma=function(p){return p!=null?(m=p,M):m},M.nodata=function(p){return p!=null?(r=k(p),M):r},M}function sa(e,t,r){let n=[],o=ei;o?s++:s--)n.push(s);return n}const ia=function(e){let t=[1,1];for(let r=1;rnew _(i)),e.length===2)[r,n]=e.map(i=>i.lab()),t=function(i){const s=[0,1,2].map(a=>r[a]+i*(n[a]-r[a]));return new _(s,"lab")};else if(e.length===3)[r,n,o]=e.map(i=>i.lab()),t=function(i){const s=[0,1,2].map(a=>(1-i)*(1-i)*r[a]+2*(1-i)*i*n[a]+i*i*o[a]);return new _(s,"lab")};else if(e.length===4){let i;[r,n,o,i]=e.map(s=>s.lab()),t=function(s){const a=[0,1,2].map(c=>(1-s)*(1-s)*(1-s)*r[c]+3*(1-s)*(1-s)*s*n[c]+3*(1-s)*s*s*o[c]+s*s*s*i[c]);return new _(a,"lab")}}else if(e.length>=5){let i,s,a;i=e.map(c=>c.lab()),a=e.length-1,s=ia(a),t=function(c){const u=1-c,f=[0,1,2].map(l=>i.reduce((h,d,b)=>h+s[b]*u**(a-b)*c**b*d[l],0));return new _(f,"lab")}}else throw new RangeError("No point in running bezier with only one color.");return t},ca=e=>{const t=aa(e);return t.scale=()=>te(t),t},{round:Rn}=Math;_.prototype.rgb=function(e=!0){return e===!1?this._rgb.slice(0,3):this._rgb.slice(0,3).map(Rn)},_.prototype.rgba=function(e=!0){return this._rgb.slice(0,4).map((t,r)=>r<3?e===!1?t:Rn(t):t)},Object.assign(k,{rgb:(...e)=>new _(...e,"rgb")}),$.format.rgb=(...e)=>{const t=E(e,"rgba");return t[3]===void 0&&(t[3]=1),t},$.autodetect.push({p:3,test:(...e)=>{if(e=E(e,"rgba"),N(e)==="array"&&(e.length===3||e.length===4&&N(e[3])=="number"&&e[3]>=0&&e[3]<=1))return"rgb"}});const nt=(e,t,r)=>{if(!nt[r])throw new Error("unknown blend mode "+r);return nt[r](e,t)},_t=e=>(t,r)=>{const n=k(r).rgb(),o=k(t).rgb();return k.rgb(e(n,o))},vt=e=>(t,r)=>{const n=[];return n[0]=e(t[0],r[0]),n[1]=e(t[1],r[1]),n[2]=e(t[2],r[2]),n},ua=e=>e,la=(e,t)=>e*t/255,fa=(e,t)=>e>t?t:e,ha=(e,t)=>e>t?e:t,da=(e,t)=>255*(1-(1-e/255)*(1-t/255)),ba=(e,t)=>t<128?2*e*t/255:255*(1-2*(1-e/255)*(1-t/255)),pa=(e,t)=>255*(1-(1-t/255)/(e/255)),ma=(e,t)=>e===255?255:(e=255*(t/255)/(1-e/255),e>255?255:e);nt.normal=_t(vt(ua)),nt.multiply=_t(vt(la)),nt.screen=_t(vt(da)),nt.overlay=_t(vt(ba)),nt.darken=_t(vt(fa)),nt.lighten=_t(vt(ha)),nt.dodge=_t(vt(ma)),nt.burn=_t(vt(pa));const{pow:ga,sin:_a,cos:va}=Math;function ya(e=300,t=-1.5,r=1,n=1,o=[0,1]){let i=0,s;N(o)==="array"?s=o[1]-o[0]:(s=0,o=[o,o]);const a=function(c){const u=ft*((e+120)/360+t*c),f=ga(o[0]+s*c,n),h=(i!==0?r[0]+c*i:r)*f*(1-f)/2,d=va(u),b=_a(u),g=f+h*(-.14861*d+1.78277*b),m=f+h*(-.29227*d-.90649*b),y=f+h*(1.97294*d);return k(xe([g*255,m*255,y*255,1]))};return a.start=function(c){return c==null?e:(e=c,a)},a.rotations=function(c){return c==null?t:(t=c,a)},a.gamma=function(c){return c==null?n:(n=c,a)},a.hue=function(c){return c==null?r:(r=c,N(r)==="array"?(i=r[1]-r[0],i===0&&(r=r[1])):i=0,a)},a.lightness=function(c){return c==null?o:(N(c)==="array"?(o=c,s=c[1]-c[0]):(o=[c,c],s=0),a)},a.scale=()=>k.scale(a),a.hue(r),a}const wa="0123456789abcdef",{floor:xa,random:Ca}=Math,ka=(e=Ca)=>{let t="#";for(let r=0;r<6;r++)t+=wa.charAt(xa(e()*16));return new _(t,"hex")},{log:qn,pow:Ra,floor:qa,abs:Ma}=Math;function Mn(e,t=null){const r={min:Number.MAX_VALUE,max:Number.MAX_VALUE*-1,sum:0,values:[],count:0};return N(e)==="object"&&(e=Object.values(e)),e.forEach(n=>{t&&N(n)==="object"&&(n=n[t]),n!=null&&!isNaN(n)&&(r.values.push(n),r.sum+=n,nr.max&&(r.max=n),r.count+=1)}),r.domain=[r.min,r.max],r.limits=(n,o)=>On(r,n,o),r}function On(e,t="equal",r=7){N(e)=="array"&&(e=Mn(e));const{min:n,max:o}=e,i=e.values.sort((a,c)=>a-c);if(r===1)return[n,o];const s=[];if(t.substr(0,1)==="c"&&(s.push(n),s.push(o)),t.substr(0,1)==="e"){s.push(n);for(let a=1;a 0");const a=Math.LOG10E*qn(n),c=Math.LOG10E*qn(o);s.push(n);for(let u=1;u200&&(l=!1)}const b={};for(let m=0;mm-y),s.push(g[0]);for(let m=1;m{e=new _(e),t=new _(t);const r=e.luminance(),n=t.luminance();return r>n?(r+.05)/(n+.05):(n+.05)/(r+.05)};const An=.027,Aa=5e-4,Sa=.1,Sn=1.14,ee=.022,$n=1.414,$a=(e,t)=>{e=new _(e),t=new _(t),e.alpha()<1&&(e=Nt(t,e,e.alpha(),"rgb"));const r=En(...e.rgb()),n=En(...t.rgb()),o=r>=ee?r:r+Math.pow(ee-r,$n),i=n>=ee?n:n+Math.pow(ee-n,$n),s=Math.pow(i,.56)-Math.pow(o,.57),a=Math.pow(i,.65)-Math.pow(o,.62),c=Math.abs(i-o)0?c-An:c+An)*100};function En(e,t,r){return .2126729*Math.pow(e/255,2.4)+.7151522*Math.pow(t/255,2.4)+.072175*Math.pow(r/255,2.4)}const{sqrt:dt,pow:F,min:Ea,max:La,atan2:Ln,abs:Nn,cos:re,sin:Tn,exp:Na,PI:jn}=Math;function Ta(e,t,r=1,n=1,o=1){var i=function(Ct){return 360*Ct/(2*jn)},s=function(Ct){return 2*jn*Ct/360};e=new _(e),t=new _(t);const[a,c,u]=Array.from(e.lab()),[f,l,h]=Array.from(t.lab()),d=(a+f)/2,b=dt(F(c,2)+F(u,2)),g=dt(F(l,2)+F(h,2)),m=(b+g)/2,y=.5*(1-dt(F(m,7)/(F(m,7)+F(25,7)))),L=c*(1+y),w=l*(1+y),x=dt(F(L,2)+F(u,2)),S=dt(F(w,2)+F(h,2)),R=(x+S)/2,M=i(Ln(u,L)),p=i(Ln(h,w)),C=M>=0?M:M+360,O=p>=0?p:p+360,q=Nn(C-O)>180?(C+O+360)/2:(C+O)/2,T=1-.17*re(s(q-30))+.24*re(s(2*q))+.32*re(s(3*q+6))-.2*re(s(4*q-63));let A=O-C;A=Nn(A)<=180?A:O<=C?A+360:A-360,A=2*dt(x*S)*Tn(s(A)/2);const P=f-a,G=S-x,Y=1+.015*F(d-50,2)/dt(20+F(d-50,2)),U=1+.045*R,ut=1+.015*R*T,xt=30*Na(-F((q-275)/25,2)),lt=-(2*dt(F(R,7)/(F(R,7)+F(25,7))))*Tn(2*s(xt)),Mt=dt(F(P/(r*Y),2)+F(G/(n*U),2)+F(A/(o*ut),2)+lt*(G/(n*U))*(A/(o*ut)));return La(0,Ea(100,Mt))}function ja(e,t,r="lab"){e=new _(e),t=new _(t);const n=e.get(r),o=t.get(r);let i=0;for(let s in n){const a=(n[s]||0)-(o[s]||0);i+=a*a}return Math.sqrt(i)}const Pa=(...e)=>{try{return new _(...e),!0}catch{return!1}},za={cool(){return te([k.hsl(180,1,.9),k.hsl(250,.7,.4)])},hot(){return te(["#000","#f00","#ff0","#fff"]).mode("rgb")}},Be={OrRd:["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"],PuBu:["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#045a8d","#023858"],BuPu:["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#810f7c","#4d004b"],Oranges:["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"],BuGn:["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#006d2c","#00441b"],YlOrBr:["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#993404","#662506"],YlGn:["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#006837","#004529"],Reds:["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"],RdPu:["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177","#49006a"],Greens:["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"],YlGnBu:["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"],Purples:["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"],GnBu:["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#0868ac","#084081"],Greys:["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"],YlOrRd:["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#bd0026","#800026"],PuRd:["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#980043","#67001f"],Blues:["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"],PuBuGn:["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016c59","#014636"],Viridis:["#440154","#482777","#3f4a8a","#31678e","#26838f","#1f9d8a","#6cce5a","#b6de2b","#fee825"],Spectral:["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"],RdYlGn:["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"],RdBu:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"],PiYG:["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"],PRGn:["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"],RdYlBu:["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"],BrBG:["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"],RdGy:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"],PuOr:["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"],Set2:["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"],Accent:["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"],Set1:["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"],Set3:["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"],Dark2:["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"],Paired:["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"],Pastel2:["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"],Pastel1:["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"]},Pn=Object.keys(Be),zn=new Map(Pn.map(e=>[e.toLowerCase(),e])),Ba=typeof Proxy=="function"?new Proxy(Be,{get(e,t){const r=t.toLowerCase();if(zn.has(r))return e[zn.get(r)]},getOwnPropertyNames(){return Object.getOwnPropertyNames(Pn)}}):Be,Ia=(...e)=>{e=E(e,"cmyk");const[t,r,n,o]=e,i=e.length>4?e[4]:1;return o===1?[0,0,0,i]:[t>=1?0:255*(1-t)*(1-o),r>=1?0:255*(1-r)*(1-o),n>=1?0:255*(1-n)*(1-o),i]},{max:Bn}=Math,Ga=(...e)=>{let[t,r,n]=E(e,"rgb");t=t/255,r=r/255,n=n/255;const o=1-Bn(t,Bn(r,n)),i=o<1?1/(1-o):0,s=(1-t-o)*i,a=(1-r-o)*i,c=(1-n-o)*i;return[s,a,c,o]};_.prototype.cmyk=function(){return Ga(this._rgb)},Object.assign(k,{cmyk:(...e)=>new _(...e,"cmyk")}),$.format.cmyk=Ia,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"cmyk"),N(e)==="array"&&e.length===4)return"cmyk"}});const Fa=(...e)=>{const t=E(e,"hsla");let r=Et(e)||"lsa";return t[0]=et(t[0]||0)+"deg",t[1]=et(t[1]*100)+"%",t[2]=et(t[2]*100)+"%",r==="hsla"||t.length>3&&t[3]<1?(t[3]="/ "+(t.length>3?t[3]:1),r="hsla"):t.length=3,`${r.substr(0,3)}(${t.join(" ")})`},Ka=(...e)=>{const t=E(e,"lab");let r=Et(e)||"lab";return t[0]=et(t[0])+"%",t[1]=et(t[1]),t[2]=et(t[2]),r==="laba"||t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`lab(${t.join(" ")})`},Xa=(...e)=>{const t=E(e,"lch");let r=Et(e)||"lab";return t[0]=et(t[0])+"%",t[1]=et(t[1]),t[2]=isNaN(t[2])?"none":et(t[2])+"deg",r==="lcha"||t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`lch(${t.join(" ")})`},Da=(...e)=>{const t=E(e,"lab");return t[0]=et(t[0]*100)+"%",t[1]=Ce(t[1]),t[2]=Ce(t[2]),t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`oklab(${t.join(" ")})`},In=(...e)=>{const[t,r,n,...o]=E(e,"rgb"),[i,s,a]=Te(t,r,n),[c,u,f]=yn(i,s,a);return[c,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]},Va=(...e)=>{const t=E(e,"lch");return t[0]=et(t[0]*100)+"%",t[1]=Ce(t[1]),t[2]=isNaN(t[2])?"none":et(t[2])+"deg",t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`oklch(${t.join(" ")})`},{round:Ie}=Math,Ya=(...e)=>{const t=E(e,"rgba");let r=Et(e)||"rgb";if(r.substr(0,3)==="hsl")return Fa(xn(t),r);if(r.substr(0,3)==="lab"){const n=Kt();ht("d50");const o=Ka(Me(t),r);return ht(n),o}if(r.substr(0,3)==="lch"){const n=Kt();ht("d50");const o=Xa($e(t),r);return ht(n),o}return r.substr(0,5)==="oklab"?Da(Te(t)):r.substr(0,5)==="oklch"?Va(In(t)):(t[0]=Ie(t[0]),t[1]=Ie(t[1]),t[2]=Ie(t[2]),(r==="rgba"||t.length>3&&t[3]<1)&&(t[3]="/ "+(t.length>3?t[3]:1),r="rgba"),`${r.substr(0,3)}(${t.slice(0,r==="rgb"?3:4).join(" ")})`)},Gn=(...e)=>{e=E(e,"lch");const[t,r,n,...o]=e,[i,s,a]=vn(t,r,n),[c,u,f]=Ne(i,s,a);return[c,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]},bt=/((?:-?\d+)|(?:-?\d+(?:\.\d+)?)%|none)/.source,ot=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)%?)|none)/.source,ne=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)%)|none)/.source,rt=/\s*/.source,zt=/\s+/.source,Ge=/\s*,\s*/.source,oe=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)(?:deg)?)|none)/.source,Bt=/\s*(?:\/\s*((?:[01]|[01]?\.\d+)|\d+(?:\.\d+)?%))?/.source,Fn=new RegExp("^rgba?\\("+rt+[bt,bt,bt].join(zt)+Bt+"\\)$"),Kn=new RegExp("^rgb\\("+rt+[bt,bt,bt].join(Ge)+rt+"\\)$"),Xn=new RegExp("^rgba\\("+rt+[bt,bt,bt,ot].join(Ge)+rt+"\\)$"),Dn=new RegExp("^hsla?\\("+rt+[oe,ne,ne].join(zt)+Bt+"\\)$"),Vn=new RegExp("^hsl?\\("+rt+[oe,ne,ne].join(Ge)+rt+"\\)$"),Yn=/^hsla\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,Zn=new RegExp("^lab\\("+rt+[ot,ot,ot].join(zt)+Bt+"\\)$"),Jn=new RegExp("^lch\\("+rt+[ot,ot,oe].join(zt)+Bt+"\\)$"),Hn=new RegExp("^oklab\\("+rt+[ot,ot,ot].join(zt)+Bt+"\\)$"),Wn=new RegExp("^oklch\\("+rt+[ot,ot,oe].join(zt)+Bt+"\\)$"),{round:Un}=Math,It=e=>e.map((t,r)=>r<=2?qt(Un(t),0,255):t),K=(e,t=0,r=100,n=!1)=>(typeof e=="string"&&e.endsWith("%")&&(e=parseFloat(e.substring(0,e.length-1))/100,n?e=t+(e+1)*.5*(r-t):e=t+e*(r-t)),+e),W=(e,t)=>e==="none"?t:e,Fe=e=>{if(e=e.toLowerCase().trim(),e==="transparent")return[0,0,0,0];let t;if($.format.named)try{return $.format.named(e)}catch{}if((t=e.match(Fn))||(t=e.match(Kn))){let r=t.slice(1,4);for(let o=0;o<3;o++)r[o]=+K(W(r[o],0),0,255);r=It(r);const n=t[4]!==void 0?+K(t[4],0,1):1;return r[3]=n,r}if(t=e.match(Xn)){const r=t.slice(1,5);for(let n=0;n<4;n++)r[n]=+K(r[n],0,255);return r}if((t=e.match(Dn))||(t=e.match(Vn))){const r=t.slice(1,4);r[0]=+W(r[0].replace("deg",""),0),r[1]=+K(W(r[1],0),0,100)*.01,r[2]=+K(W(r[2],0),0,100)*.01;const n=It(Le(r)),o=t[4]!==void 0?+K(t[4],0,1):1;return n[3]=o,n}if(t=e.match(Yn)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=Le(r);for(let o=0;o<3;o++)n[o]=Un(n[o]);return n[3]=+t[4],n}if(t=e.match(Zn)){const r=t.slice(1,4);r[0]=K(W(r[0],0),0,100),r[1]=K(W(r[1],0),-125,125,!0),r[2]=K(W(r[2],0),-125,125,!0);const n=Kt();ht("d50");const o=It(Re(r));ht(n);const i=t[4]!==void 0?+K(t[4],0,1):1;return o[3]=i,o}if(t=e.match(Jn)){const r=t.slice(1,4);r[0]=K(r[0],0,100),r[1]=K(W(r[1],0),0,150,!1),r[2]=+W(r[2].replace("deg",""),0);const n=Kt();ht("d50");const o=It(Se(r));ht(n);const i=t[4]!==void 0?+K(t[4],0,1):1;return o[3]=i,o}if(t=e.match(Hn)){const r=t.slice(1,4);r[0]=K(W(r[0],0),0,1),r[1]=K(W(r[1],0),-.4,.4,!0),r[2]=K(W(r[2],0),-.4,.4,!0);const n=It(Ne(r)),o=t[4]!==void 0?+K(t[4],0,1):1;return n[3]=o,n}if(t=e.match(Wn)){const r=t.slice(1,4);r[0]=K(W(r[0],0),0,1),r[1]=K(W(r[1],0),0,.4,!1),r[2]=+W(r[2].replace("deg",""),0);const n=It(Gn(r)),o=t[4]!==void 0?+K(t[4],0,1):1;return n[3]=o,n}};Fe.test=e=>Fn.test(e)||Dn.test(e)||Zn.test(e)||Jn.test(e)||Hn.test(e)||Wn.test(e)||Kn.test(e)||Xn.test(e)||Vn.test(e)||Yn.test(e)||e==="transparent",_.prototype.css=function(e){return Ya(this._rgb,e)};const Za=(...e)=>new _(...e,"css");k.css=Za,$.format.css=Fe,$.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&N(e)==="string"&&Fe.test(e))return"css"}}),$.format.gl=(...e)=>{const t=E(e,"rgba");return t[0]*=255,t[1]*=255,t[2]*=255,t};const Ja=(...e)=>new _(...e,"gl");k.gl=Ja,_.prototype.gl=function(){const e=this._rgb;return[e[0]/255,e[1]/255,e[2]/255,e[3]]},_.prototype.hex=function(e){return mn(this._rgb,e)};const Ha=(...e)=>new _(...e,"hex");k.hex=Ha,$.format.hex=pn,$.autodetect.push({p:4,test:(e,...t)=>{if(!t.length&&N(e)==="string"&&[3,4,5,6,7,8,9].indexOf(e.length)>=0)return"hex"}});const{log:se}=Math,Qn=e=>{const t=e/100;let r,n,o;return t<66?(r=255,n=t<6?0:-155.25485562709179-.44596950469579133*(n=t-2)+104.49216199393888*se(n),o=t<20?0:-254.76935184120902+.8274096064007395*(o=t-10)+115.67994401066147*se(o)):(r=351.97690566805693+.114206453784165*(r=t-55)-40.25366309332127*se(r),n=325.4494125711974+.07943456536662342*(n=t-50)-28.0852963507957*se(n),o=255),[r,n,o,1]},{round:Wa}=Math,Ua=(...e)=>{const t=E(e,"rgb"),r=t[0],n=t[2];let o=1e3,i=4e4;const s=.4;let a;for(;i-o>s;){a=(i+o)*.5;const c=Qn(a);c[2]/c[0]>=n/r?i=a:o=a}return Wa(a)};_.prototype.temp=_.prototype.kelvin=_.prototype.temperature=function(){return Ua(this._rgb)};const Ke=(...e)=>new _(...e,"temp");Object.assign(k,{temp:Ke,kelvin:Ke,temperature:Ke}),$.format.temp=$.format.kelvin=$.format.temperature=Qn,_.prototype.oklch=function(){return In(this._rgb)},Object.assign(k,{oklch:(...e)=>new _(...e,"oklch")}),$.format.oklch=Gn,$.autodetect.push({p:2,test:(...e)=>{if(e=E(e,"oklch"),N(e)==="array"&&e.length===3)return"oklch"}}),Object.assign(k,{analyze:Mn,average:ra,bezier:ca,blend:nt,brewer:Ba,Color:_,colors:Lt,contrast:Oa,contrastAPCA:$a,cubehelix:ya,deltaE:Ta,distance:ja,input:$,interpolate:Nt,limits:On,mix:Nt,random:ka,scale:te,scales:za,valid:Pa});class v{constructor(){this.hex="#000000",this.rgb_r=0,this.rgb_g=0,this.rgb_b=0,this.xyz_x=0,this.xyz_y=0,this.xyz_z=0,this.luv_l=0,this.luv_u=0,this.luv_v=0,this.lch_l=0,this.lch_c=0,this.lch_h=0,this.hsluv_h=0,this.hsluv_s=0,this.hsluv_l=0,this.hpluv_h=0,this.hpluv_p=0,this.hpluv_l=0,this.r0s=0,this.r0i=0,this.r1s=0,this.r1i=0,this.g0s=0,this.g0i=0,this.g1s=0,this.g1i=0,this.b0s=0,this.b0i=0,this.b1s=0,this.b1i=0}static fromLinear(t){return t<=.0031308?12.92*t:1.055*Math.pow(t,.4166666666666667)-.055}static toLinear(t){return t>.04045?Math.pow((t+.055)/1.055,2.4):t/12.92}static yToL(t){return t<=v.epsilon?t/v.refY*v.kappa:116*Math.pow(t/v.refY,.3333333333333333)-16}static lToY(t){return t<=8?v.refY*t/v.kappa:v.refY*Math.pow((t+16)/116,3)}static rgbChannelToHex(t){const r=Math.round(t*255),n=r%16,o=(r-n)/16|0;return v.hexChars.charAt(o)+v.hexChars.charAt(n)}static hexToRgbChannel(t,r){const n=v.hexChars.indexOf(t.charAt(r)),o=v.hexChars.indexOf(t.charAt(r+1));return(n*16+o)/255}static distanceFromOriginAngle(t,r,n){const o=r/(Math.sin(n)-t*Math.cos(n));return o<0?1/0:o}static distanceFromOrigin(t,r){return Math.abs(r)/Math.sqrt(Math.pow(t,2)+1)}static min6(t,r,n,o,i,s){return Math.min(t,Math.min(r,Math.min(n,Math.min(o,Math.min(i,s)))))}rgbToHex(){this.hex="#",this.hex+=v.rgbChannelToHex(this.rgb_r),this.hex+=v.rgbChannelToHex(this.rgb_g),this.hex+=v.rgbChannelToHex(this.rgb_b)}hexToRgb(){this.hex=this.hex.toLowerCase(),this.rgb_r=v.hexToRgbChannel(this.hex,1),this.rgb_g=v.hexToRgbChannel(this.hex,3),this.rgb_b=v.hexToRgbChannel(this.hex,5)}xyzToRgb(){this.rgb_r=v.fromLinear(v.m_r0*this.xyz_x+v.m_r1*this.xyz_y+v.m_r2*this.xyz_z),this.rgb_g=v.fromLinear(v.m_g0*this.xyz_x+v.m_g1*this.xyz_y+v.m_g2*this.xyz_z),this.rgb_b=v.fromLinear(v.m_b0*this.xyz_x+v.m_b1*this.xyz_y+v.m_b2*this.xyz_z)}rgbToXyz(){const t=v.toLinear(this.rgb_r),r=v.toLinear(this.rgb_g),n=v.toLinear(this.rgb_b);this.xyz_x=.41239079926595*t+.35758433938387*r+.18048078840183*n,this.xyz_y=.21263900587151*t+.71516867876775*r+.072192315360733*n,this.xyz_z=.019330818715591*t+.11919477979462*r+.95053215224966*n}xyzToLuv(){const t=this.xyz_x+15*this.xyz_y+3*this.xyz_z;let r=4*this.xyz_x,n=9*this.xyz_y;t!==0?(r/=t,n/=t):(r=NaN,n=NaN),this.luv_l=v.yToL(this.xyz_y),this.luv_l===0?(this.luv_u=0,this.luv_v=0):(this.luv_u=13*this.luv_l*(r-v.refU),this.luv_v=13*this.luv_l*(n-v.refV))}luvToXyz(){if(this.luv_l===0){this.xyz_x=0,this.xyz_y=0,this.xyz_z=0;return}const t=this.luv_u/(13*this.luv_l)+v.refU,r=this.luv_v/(13*this.luv_l)+v.refV;this.xyz_y=v.lToY(this.luv_l),this.xyz_x=0-9*this.xyz_y*t/((t-4)*r-t*r),this.xyz_z=(9*this.xyz_y-15*r*this.xyz_y-r*this.xyz_x)/(3*r)}luvToLch(){if(this.lch_l=this.luv_l,this.lch_c=Math.sqrt(this.luv_u*this.luv_u+this.luv_v*this.luv_v),this.lch_c<1e-8)this.lch_h=0;else{const t=Math.atan2(this.luv_v,this.luv_u);this.lch_h=t*180/Math.PI,this.lch_h<0&&(this.lch_h=360+this.lch_h)}}lchToLuv(){const t=this.lch_h/180*Math.PI;this.luv_l=this.lch_l,this.luv_u=Math.cos(t)*this.lch_c,this.luv_v=Math.sin(t)*this.lch_c}calculateBoundingLines(t){const r=Math.pow(t+16,3)/1560896,n=r>v.epsilon?r:t/v.kappa,o=n*(284517*v.m_r0-94839*v.m_r2),i=n*(838422*v.m_r2+769860*v.m_r1+731718*v.m_r0),s=n*(632260*v.m_r2-126452*v.m_r1),a=n*(284517*v.m_g0-94839*v.m_g2),c=n*(838422*v.m_g2+769860*v.m_g1+731718*v.m_g0),u=n*(632260*v.m_g2-126452*v.m_g1),f=n*(284517*v.m_b0-94839*v.m_b2),l=n*(838422*v.m_b2+769860*v.m_b1+731718*v.m_b0),h=n*(632260*v.m_b2-126452*v.m_b1);this.r0s=o/s,this.r0i=i*t/s,this.r1s=o/(s+126452),this.r1i=(i-769860)*t/(s+126452),this.g0s=a/u,this.g0i=c*t/u,this.g1s=a/(u+126452),this.g1i=(c-769860)*t/(u+126452),this.b0s=f/h,this.b0i=l*t/h,this.b1s=f/(h+126452),this.b1i=(l-769860)*t/(h+126452)}calcMaxChromaHpluv(){const t=v.distanceFromOrigin(this.r0s,this.r0i),r=v.distanceFromOrigin(this.r1s,this.r1i),n=v.distanceFromOrigin(this.g0s,this.g0i),o=v.distanceFromOrigin(this.g1s,this.g1i),i=v.distanceFromOrigin(this.b0s,this.b0i),s=v.distanceFromOrigin(this.b1s,this.b1i);return v.min6(t,r,n,o,i,s)}calcMaxChromaHsluv(t){const r=t/360*Math.PI*2,n=v.distanceFromOriginAngle(this.r0s,this.r0i,r),o=v.distanceFromOriginAngle(this.r1s,this.r1i,r),i=v.distanceFromOriginAngle(this.g0s,this.g0i,r),s=v.distanceFromOriginAngle(this.g1s,this.g1i,r),a=v.distanceFromOriginAngle(this.b0s,this.b0i,r),c=v.distanceFromOriginAngle(this.b1s,this.b1i,r);return v.min6(n,o,i,s,a,c)}hsluvToLch(){if(this.hsluv_l>99.9999999)this.lch_l=100,this.lch_c=0;else if(this.hsluv_l<1e-8)this.lch_l=0,this.lch_c=0;else{this.lch_l=this.hsluv_l,this.calculateBoundingLines(this.hsluv_l);const t=this.calcMaxChromaHsluv(this.hsluv_h);this.lch_c=t/100*this.hsluv_s}this.lch_h=this.hsluv_h}lchToHsluv(){if(this.lch_l>99.9999999)this.hsluv_s=0,this.hsluv_l=100;else if(this.lch_l<1e-8)this.hsluv_s=0,this.hsluv_l=0;else{this.calculateBoundingLines(this.lch_l);const t=this.calcMaxChromaHsluv(this.lch_h);this.hsluv_s=this.lch_c/t*100,this.hsluv_l=this.lch_l}this.hsluv_h=this.lch_h}hpluvToLch(){if(this.hpluv_l>99.9999999)this.lch_l=100,this.lch_c=0;else if(this.hpluv_l<1e-8)this.lch_l=0,this.lch_c=0;else{this.lch_l=this.hpluv_l,this.calculateBoundingLines(this.hpluv_l);const t=this.calcMaxChromaHpluv();this.lch_c=t/100*this.hpluv_p}this.lch_h=this.hpluv_h}lchToHpluv(){if(this.lch_l>99.9999999)this.hpluv_p=0,this.hpluv_l=100;else if(this.lch_l<1e-8)this.hpluv_p=0,this.hpluv_l=0;else{this.calculateBoundingLines(this.lch_l);const t=this.calcMaxChromaHpluv();this.hpluv_p=this.lch_c/t*100,this.hpluv_l=this.lch_l}this.hpluv_h=this.lch_h}hsluvToRgb(){this.hsluvToLch(),this.lchToLuv(),this.luvToXyz(),this.xyzToRgb()}hpluvToRgb(){this.hpluvToLch(),this.lchToLuv(),this.luvToXyz(),this.xyzToRgb()}hsluvToHex(){this.hsluvToRgb(),this.rgbToHex()}hpluvToHex(){this.hpluvToRgb(),this.rgbToHex()}rgbToHsluv(){this.rgbToXyz(),this.xyzToLuv(),this.luvToLch(),this.lchToHpluv(),this.lchToHsluv()}rgbToHpluv(){this.rgbToXyz(),this.xyzToLuv(),this.luvToLch(),this.lchToHpluv(),this.lchToHpluv()}hexToHsluv(){this.hexToRgb(),this.rgbToHsluv()}hexToHpluv(){this.hexToRgb(),this.rgbToHpluv()}}v.hexChars="0123456789abcdef",v.refY=1,v.refU=.19783000664283,v.refV=.46831999493879,v.kappa=903.2962962,v.epsilon=.0088564516,v.m_r0=3.240969941904521,v.m_r1=-1.537383177570093,v.m_r2=-.498610760293,v.m_g0=-.96924363628087,v.m_g1=1.87596750150772,v.m_g2=.041555057407175,v.m_b0=.055630079696993,v.m_b1=-.20397695888897,v.m_b2=1.056971514242878;function to(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var ie={exports:{}},Xe,eo;function Xt(){if(eo)return Xe;eo=1;function e(t,r){return Object.prototype.hasOwnProperty.call(t,r)}return Xe=e,Xe}var De,ro;function Ve(){if(ro)return De;ro=1;var e=Xt(),t,r;function n(){r=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],t=!0;for(var s in{toString:null})t=!1}function o(s,a,c){var u,f=0;t==null&&n();for(u in s)if(i(a,s,u,c)===!1)break;if(t)for(var l=s.constructor,h=!!l&&s===l.prototype;(u=r[f++])&&!((u!=="constructor"||!h&&e(s,u))&&s[u]!==Object.prototype[u]&&i(a,s,u,c)===!1););}function i(s,a,c,u){return s.call(u,a[c],c,a)}return De=o,De}var Ye,no;function oo(){if(no)return Ye;no=1;var e=Ve();function t(r){var n=[];return e(r,function(o,i){typeof o=="function"&&n.push(i)}),n.sort()}return Ye=t,Ye}var Ze,so;function Dt(){if(so)return Ze;so=1;function e(t,r,n){var o=t.length;r==null?r=0:r<0?r=Math.max(o+r,0):r=Math.min(r,o),n==null?n=o:n<0?n=Math.max(o+n,0):n=Math.min(n,o);for(var i=[];r1?n(arguments,1):e(i);r(a,function(c){i[c]=t(i[c],i)})}return Ue=o,Ue}var Qe,uo;function V(){if(uo)return Qe;uo=1;var e=Xt(),t=Ve();function r(n,o,i){t(n,function(s,a){if(e(n,a))return o.call(i,n[a],a,n)})}return Qe=r,Qe}var tr,lo;function ec(){if(lo)return tr;lo=1;function e(t){return t}return tr=e,tr}var er,fo;function ho(){if(fo)return er;fo=1;function e(t){return function(r){return r[t]}}return er=e,er}var rr,bo;function nr(){if(bo)return rr;bo=1;var e=/^\[object (.*)\]$/,t=Object.prototype.toString,r;function n(o){return o===null?"Null":o===r?"Undefined":e.exec(t.call(o))[1]}return rr=n,rr}var or,po;function sr(){if(po)return or;po=1;var e=nr();function t(r,n){return e(r)===n}return or=t,or}var ir,mo;function rc(){if(mo)return ir;mo=1;var e=sr(),t=Array.isArray||function(r){return e(r,"Array")};return ir=t,ir}var ar,go;function _o(){if(go)return ar;go=1;var e=V(),t=rc();function r(s,a){for(var c=-1,u=s.length;++cs&&(s=c,i=a);return i}return Or=t,Or}var Ar,Do;function Sr(){if(Do)return Ar;Do=1;var e=V();function t(r){var n=[];return e(r,function(o,i){n.push(o)}),n}return Ar=t,Ar}var $r,Vo;function bc(){if(Vo)return $r;Vo=1;var e=dc(),t=Sr();function r(n,o){return e(t(n),o)}return $r=r,$r}var Er,Yo;function Zo(){if(Yo)return Er;Yo=1;var e=V();function t(n,o){for(var i=0,s=arguments.length,a;++i2;if(!t(n)&&!a)throw new Error("reduce of empty object with no initial value");return e(n,function(c,u,f){a?i=o.call(s,i,c,u,f):(i=c,a=!0)}),i}return Dr=r,Dr}var Vr,ls;function qc(){if(ls)return Vr;ls=1;var e=Lo(),t=yt();function r(n,o,i){return o=t(o,i),e(n,function(s,a,c){return!o(s,a,c)},i)}return Vr=r,Vr}var Yr,fs;function Mc(){if(fs)return Yr;fs=1;var e=sr();function t(r){return e(r,"Function")}return Yr=t,Yr}var Zr,hs;function Oc(){if(hs)return Zr;hs=1;var e=Mc();function t(r,n){var o=r[n];if(o!==void 0)return e(o)?o.call(r):o}return Zr=t,Zr}var Jr,ds;function Ac(){if(ds)return Jr;ds=1;var e=es();function t(r,n,o){var i=/^(.+)\.(.+)$/.exec(n);i?e(r,i[1])[i[2]]=o:r[n]=o}return Jr=t,Jr}var Hr,bs;function Sc(){if(bs)return Hr;bs=1;var e=Bo();function t(r,n){if(e(r,n)){for(var o=n.split("."),i=o.pop();n=o.shift();)r=r[n];return delete r[i]}else return!0}return Hr=t,Hr}var Wr,ps;function Ur(){return ps||(ps=1,Wr={bindAll:tc(),contains:nc(),deepFillIn:oc(),deepMatches:_o(),deepMixIn:sc(),equals:ac(),every:qo(),fillIn:cc(),filter:Lo(),find:uc(),flatten:lc(),forIn:Ve(),forOwn:V(),functions:oo(),get:Po(),has:Bo(),hasOwn:Xt(),keys:fc(),map:Fo(),matches:hc(),max:bc(),merge:gc(),min:vc(),mixIn:Zo(),namespace:es(),omit:xc(),pick:Cc(),pluck:kc(),reduce:Rc(),reject:qc(),result:Oc(),set:Ac(),size:cs(),some:lr(),unset:Sc(),values:Sr()}),Wr}var ms;function gs(){return ms||(ms=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Ur(),n={A:{x:.44758,y:.40745},C:{x:.31006,y:.31616},D50:{x:.34567,y:.35851},D65:{x:.31272,y:.32903},D55:{x:.33243,y:.34744},D75:{x:.29903,y:.31488}},o=(0,r.map)(n,function(i){var s=100*(i.x/i.y),a=100,c=100*(1-i.x-i.y)/i.y;return[s,a,c]});t.default=o,e.exports=t.default})(ie,ie.exports)),ie.exports}var ae={exports:{}},_s;function vs(){return _s||(_s=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Math,n=r.pow,o=r.sign,i=r.abs,s={decode:function(l){return l<=.04045?l/12.92:n((l+.055)/1.055,2.4)},encode:function(l){return l<=.0031308?12.92*l:1.055*n(l,1/2.4)-.055}},a={encode:function(l){return l<.001953125?16*l:n(l,1/1.8)},decode:function(l){return l<16*.001953125?l/16:n(l,1.8)}};function c(f){return{decode:function(h){return o(h)*n(i(h),f)},encode:function(h){return o(h)*n(i(h),1/f)}}}var u={sRGB:{r:{x:.64,y:.33},g:{x:.3,y:.6},b:{x:.15,y:.06},gamma:s},"Adobe RGB":{r:{x:.64,y:.33},g:{x:.21,y:.71},b:{x:.15,y:.06},gamma:c(2.2)},"Wide Gamut RGB":{r:{x:.7347,y:.2653},g:{x:.1152,y:.8264},b:{x:.1566,y:.0177},gamma:c(563/256)},"ProPhoto RGB":{r:{x:.7347,y:.2653},g:{x:.1596,y:.8404},b:{x:.0366,y:1e-4},gamma:a}};t.default=u,e.exports=t.default})(ae,ae.exports)),ae.exports}var pt={},ys;function ws(){if(ys)return pt;ys=1,Object.defineProperty(pt,"__esModule",{value:!0});function e(s){return[[s[0][0],s[1][0],s[2][0]],[s[0][1],s[1][1],s[2][1]],[s[0][2],s[1][2],s[2][2]]]}function t(s){return s[0][0]*(s[2][2]*s[1][1]-s[2][1]*s[1][2])+s[1][0]*(s[2][1]*s[0][2]-s[2][2]*s[0][1])+s[2][0]*(s[1][2]*s[0][1]-s[1][1]*s[0][2])}function r(s){var a=1/t(s);return[[(s[2][2]*s[1][1]-s[2][1]*s[1][2])*a,(s[2][1]*s[0][2]-s[2][2]*s[0][1])*a,(s[1][2]*s[0][1]-s[1][1]*s[0][2])*a],[(s[2][0]*s[1][2]-s[2][2]*s[1][0])*a,(s[2][2]*s[0][0]-s[2][0]*s[0][2])*a,(s[1][0]*s[0][2]-s[1][2]*s[0][0])*a],[(s[2][1]*s[1][0]-s[2][0]*s[1][1])*a,(s[2][0]*s[0][1]-s[2][1]*s[0][0])*a,(s[1][1]*s[0][0]-s[1][0]*s[0][1])*a]]}function n(s,a){return[s[0][0]*a[0]+s[0][1]*a[1]+s[0][2]*a[2],s[1][0]*a[0]+s[1][1]*a[1]+s[1][2]*a[2],s[2][0]*a[0]+s[2][1]*a[1]+s[2][2]*a[2]]}function o(s,a){return[[s[0][0]*a[0],s[0][1]*a[1],s[0][2]*a[2]],[s[1][0]*a[0],s[1][1]*a[1],s[1][2]*a[2]],[s[2][0]*a[0],s[2][1]*a[1],s[2][2]*a[2]]]}function i(s,a){return[[s[0][0]*a[0][0]+s[0][1]*a[1][0]+s[0][2]*a[2][0],s[0][0]*a[0][1]+s[0][1]*a[1][1]+s[0][2]*a[2][1],s[0][0]*a[0][2]+s[0][1]*a[1][2]+s[0][2]*a[2][2]],[s[1][0]*a[0][0]+s[1][1]*a[1][0]+s[1][2]*a[2][0],s[1][0]*a[0][1]+s[1][1]*a[1][1]+s[1][2]*a[2][1],s[1][0]*a[0][2]+s[1][1]*a[1][2]+s[1][2]*a[2][2]],[s[2][0]*a[0][0]+s[2][1]*a[1][0]+s[2][2]*a[2][0],s[2][0]*a[0][1]+s[2][1]*a[1][1]+s[2][2]*a[2][1],s[2][0]*a[0][2]+s[2][1]*a[1][2]+s[2][2]*a[2][2]]]}return pt.transpose=e,pt.determinant=t,pt.inverse=r,pt.multiply=n,pt.scalar=o,pt.product=i,pt}var Yt={},xs;function $c(){if(xs)return Yt;xs=1,Object.defineProperty(Yt,"__esModule",{value:!0});var e=Math,t=e.PI;function r(o){for(var i=o*180/t;i<0;)i+=360;for(;i>360;)i-=360;return i}function n(o){for(var i=t*o/180;i<0;)i+=2*t;for(;i>2*t;)i-=2*t;return i}return Yt.fromRadian=r,Yt.toRadian=n,Yt}var Zt={},Cs;function Ec(){if(Cs)return Zt;Cs=1,Object.defineProperty(Zt,"__esModule",{value:!0});var e=Math,t=e.round;function r(o){return o[0]=="#"&&(o=o.slice(1)),o.length<6&&(o=o.split("").map(function(i){return i+i}).join("")),o.match(/../g).map(function(i){return parseInt(i,16)/255})}function n(o){var i=o.map(function(s){return s=t(255*s).toString(16),s.length<2&&(s="0"+s),s}).join("");return"#"+i}return Zt.fromHex=r,Zt.toHex=n,Zt}var ce={exports:{}},ks;function Lc(){return ks||(ks=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=ws(),n=u(r),o=gs(),i=c(o),s=vs(),a=c(s);function c(l){return l&&l.__esModule?l:{default:l}}function u(l){if(l&&l.__esModule)return l;var h={};if(l!=null)for(var d in l)Object.prototype.hasOwnProperty.call(l,d)&&(h[d]=l[d]);return h.default=l,h}function f(){var l=arguments.length<=0||arguments[0]===void 0?a.default.sRGB:arguments[0],h=arguments.length<=1||arguments[1]===void 0?i.default.D65:arguments[1],d=[l.r,l.g,l.b],b=n.transpose(d.map(function(w){var x=w.x/w.y,S=1,R=(1-w.x-w.y)/w.y;return[x,S,R]})),g=l.gamma,m=n.multiply(n.inverse(b),h),y=n.scalar(b,m),L=n.inverse(y);return{fromRgb:function(x){return n.multiply(y,x.map(g.decode))},toRgb:function(x){return n.multiply(L,x).map(g.encode)}}}t.default=f,e.exports=t.default})(ce,ce.exports)),ce.exports}var Qr,Rs;function ue(){if(Rs)return Qr;Rs=1;var e=gs(),t=vs(),r=ws(),n=$c(),o=Ec(),i=Lc();return Qr={illuminant:e,workspace:t,matrix:r,degree:n,rgb:o,xyz:i},Qr}var Nc=ue();const le=to(Nc);var st={},qs;function fe(){if(qs)return st;qs=1,Object.defineProperty(st,"__esModule",{value:!0}),st.cfs=st.distance=st.lerp=st.corLerp=void 0;var e=Ur();function t(h,d,b){return d in h?Object.defineProperty(h,d,{value:b,enumerable:!0,configurable:!0,writable:!0}):h[d]=b,h}function r(h){if(Array.isArray(h)){for(var d=0,b=Array(h.length);dm/2&&(h>d?d+=m:h+=m)}return((1-b)*h+b*d)%(m||1/0)}function u(h,d,b){var g={};for(var m in h)g[m]=c(h[m],d[m],b,m);return g}function f(h,d){var b=0;for(var g in h)b+=i(h[g]-d[g],2);return s(b)}function l(h){return e.merge.apply(void 0,r(h.split("").map(function(d){return t({},d,!0)})))}return st.corLerp=c,st.lerp=u,st.distance=f,st.cfs=l,st}var he={exports:{}},Ms;function Tc(){return Ms||(Ms=1,(function(e,t){var r=(function(){function s(a,c){var u=[],f=!0,l=!1,h=void 0;try{for(var d=a[Symbol.iterator](),b;!(f=(b=d.next()).done)&&(u.push(b.value),!(c&&u.length===c));f=!0);}catch(g){l=!0,h=g}finally{try{!f&&d.return&&d.return()}finally{if(l)throw h}}return u}return function(a,c){if(Array.isArray(a))return a;if(Symbol.iterator in Object(a))return s(a,c);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(t,"__esModule",{value:!0});var n=ue(),o=fe();function i(s,a){var c=arguments.length<=2||arguments[2]===void 0?1e-6:arguments[2],u=-c,f=1+c,l=Math,h=l.min,d=l.max,b=["000","fff"].map(function(R){return a.fromXyz(s.fromRgb(n.rgb.fromHex(R)))}),g=r(b,2),m=g[0],y=g[1];function L(R){var M=s.toRgb(a.toXyz(R)),p=M.map(function(C){return C>=u&&C<=f}).reduce(function(C,O){return C&&O},!0);return[p,M]}function w(R,M){for(var p=arguments.length<=2||arguments[2]===void 0?.001:arguments[2];(0,o.distance)(R,M)>p;){var C=(0,o.lerp)(R,M,.5),O=L(C),q=r(O,1),T=q[0];T?R=C:M=C}return R}function x(R){return(0,o.lerp)(m,y,R)}function S(R){return R.map(function(M){return d(u,h(f,M))})}return{contains:L,limit:w,spine:x,crop:S}}t.default=i,e.exports=t.default})(he,he.exports)),he.exports}var de={exports:{}},it={},Os;function As(){if(Os)return it;Os=1;var e=(function(){function l(h,d){var b=[],g=!0,m=!1,y=void 0;try{for(var L=h[Symbol.iterator](),w;!(g=(w=L.next()).done)&&(b.push(w.value),!(d&&b.length===d));g=!0);}catch(x){m=!0,y=x}finally{try{!g&&L.return&&L.return()}finally{if(m)throw y}}return b}return function(h,d){if(Array.isArray(h))return h;if(Symbol.iterator in Object(h))return l(h,d);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(it,"__esModule",{value:!0}),it.toNotation=it.fromNotation=it.toHue=it.fromHue=void 0;var t=fe(),r=Math,n=r.floor,o=[{s:"R",h:20.14,e:.8,H:0},{s:"Y",h:90,e:.7,H:100},{s:"G",h:164.25,e:1,H:200},{s:"B",h:237.53,e:1.2,H:300},{s:"R",h:380.14,e:.8,H:400}],i=o.map(function(l){return l.s}).slice(0,-1).join("");function s(l){l50){var g=[d,h];h=g[0],d=g[1],b=100-b}return b<1?i[h]:i[h]+b.toFixed()+i[d]}return it.fromHue=s,it.toHue=a,it.fromNotation=u,it.toNotation=f,it}var Ss;function jc(){return Ss||(Ss=1,(function(e,t){var r=(function(){function P(G,Y){var U=[],ut=!0,xt=!1,Jt=void 0;try{for(var lt=G[Symbol.iterator](),Mt;!(ut=(Mt=lt.next()).done)&&(U.push(Mt.value),!(Y&&U.length===Y));ut=!0);}catch(Ct){xt=!0,Jt=Ct}finally{try{!ut&<.return&<.return()}finally{if(xt)throw Jt}}return U}return function(G,Y){if(Array.isArray(G))return G;if(Symbol.iterator in Object(G))return P(G,Y);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(t,"__esModule",{value:!0});var n=ue(),o=As(),i=c(o),s=fe(),a=Ur();function c(P){if(P&&P.__esModule)return P;var G={};if(P!=null)for(var Y in P)Object.prototype.hasOwnProperty.call(P,Y)&&(G[Y]=P[Y]);return G.default=P,G}var u=Math,f=u.pow,l=u.sqrt,h=u.exp,d=u.abs,b=u.sign,g=Math,m=g.sin,y=g.cos,L=g.atan2,w={average:{F:1,c:.69,N_c:1},dim:{F:.9,c:.59,N_c:.9},dark:{F:.8,c:.535,N_c:.8}},x=[[.7328,.4296,-.1624],[-.7036,1.6975,.0061],[.003,.0136,.9834]],S=[[.38971,.68898,-.07868],[-.22981,1.1834,.04641],[0,0,1]],R=x,M=n.matrix.inverse(x),p=n.matrix.product(S,n.matrix.inverse(x)),C=n.matrix.product(x,n.matrix.inverse(S)),O={whitePoint:n.illuminant.D65,adaptingLuminance:40,backgroundLuminance:20,surroundType:"average",discounting:!1},q=(0,s.cfs)("QJMCshH"),T=(0,s.cfs)("JCh");function A(){var P=arguments.length<=0||arguments[0]===void 0?{}:arguments[0],G=arguments.length<=1||arguments[1]===void 0?q:arguments[1];P=(0,a.merge)(O,P);var Y=P.whitePoint,U=P.adaptingLuminance,ut=P.backgroundLuminance,xt=w[P.surroundType],Jt=xt.F,lt=xt.c,Mt=xt.N_c,Ct=Y[1],Ys=1/(5*U+1),Ot=.2*f(Ys,4)*5*U+.1*f(1-f(Ys,4),2)*f(5*U,1/3),_e=ut/Ct,an=.725*f(1/_e,.2),Zs=an,Js=1.48+l(_e),Hs=P.discounting?1:Jt*(1-1/3.6*h(-(U+42)/92)),d0=n.matrix.multiply(x,Y),b0=d0.map(function(j){return Hs*Ct/j+1-Hs}),cn=r(b0,3),Ws=cn[0],Us=cn[1],Qs=cn[2],p0=ti(Y),m0=ei(p0),ve=ri(m0);function ti(j){var z=n.matrix.multiply(R,j),B=r(z,3),Z=B[0],D=B[1],tt=B[2];return[Ws*Z,Us*D,Qs*tt]}function g0(j){var z=r(j,3),B=z[0],Z=z[1],D=z[2];return n.matrix.multiply(M,[B/Ws,Z/Us,D/Qs])}function ei(j){return n.matrix.multiply(p,j).map(function(z){var B=f(Ot*d(z)/100,.42);return b(z)*400*B/(27.13+B)+.1})}function _0(j){return n.matrix.multiply(C,j.map(function(z){var B=z-.1;return b(B)*100/Ot*f(27.13*d(B)/(400-d(B)),2.380952380952381)}))}function ri(j){var z=r(j,3),B=z[0],Z=z[1],D=z[2];return(B*2+Z+D/20-.305)*an}function un(j){return 4/lt*l(j/100)*(ve+4)*f(Ot,.25)}function v0(j){return 6.25*f(lt*j/((ve+4)*f(Ot,.25)),2)}function ni(j){return j*f(Ot,.25)}function y0(j,z){return f(j/100,2)*z/f(Ot,.25)}function w0(j){return j/f(Ot,.25)}function x0(j,z){return 100*l(j/z)}function ln(j,z){var B=z.Q,Z=z.J,D=z.M,tt=z.C,at=z.s,mt=z.h,gt=z.H,J={};return j.J&&(J.J=isNaN(Z)?v0(B):Z),j.C&&(isNaN(tt)?isNaN(D)?(B=isNaN(B)?un(Z):B,J.C=y0(at,B)):J.C=w0(D):J.C=z.C),j.h&&(J.h=isNaN(mt)?i.toHue(gt):mt),j.Q&&(J.Q=isNaN(B)?un(Z):B),j.M&&(J.M=isNaN(D)?ni(tt):D),j.s&&(isNaN(at)?(B=isNaN(B)?un(Z):B,D=isNaN(D)?ni(tt):D,J.s=x0(D,B)):J.s=at),j.H&&(J.H=isNaN(gt)?i.fromHue(mt):gt),J}function C0(j){var z=ti(j),B=ei(z),Z=r(B,3),D=Z[0],tt=Z[1],at=Z[2],mt=D-tt*12/11+at/11,gt=(D+tt-2*at)/9,J=L(gt,mt),Ft=n.degree.fromRadian(J),ye=1/4*(y(J+2)+3.8),we=ri(B),Ht=100*f(we/ve,lt*Js),kt=5e4/13*Mt*Zs*ye*l(mt*mt+gt*gt)/(D+tt+21/20*at),Rt=f(kt,.9)*l(Ht/100)*f(1.64-f(.29,_e),.73);return ln(G,{J:Ht,C:Rt,h:Ft})}function k0(j){var z=ln(T,j),B=z.J,Z=z.C,D=z.h,tt=n.degree.toRadian(D),at=f(Z/(l(B/100)*f(1.64-f(.29,_e),.73)),10/9),mt=1/4*(y(tt+2)+3.8),gt=ve*f(B/100,1/lt/Js),J=5e4/13*Mt*Zs*mt/at,Ft=gt/an+.305,ye=Ft*61/20*460/1403,we=61/20*220/1403,Ht=21/20*6300/1403-27/1403,kt=m(tt),Rt=y(tt),At,St;at===0||isNaN(at)?At=St=0:d(kt)>=d(Rt)?(St=ye/(J/kt+we*Rt/kt+Ht),At=St*Rt/kt):(At=ye/(J/Rt+we+Ht*kt/Rt),St=At*kt/Rt);var R0=[20/61*Ft+451/1403*At+288/1403*St,20/61*Ft-891/1403*At-261/1403*St,20/61*Ft-220/1403*At-6300/1403*St],q0=_0(R0),M0=g0(q0);return M0}return{fromXyz:C0,toXyz:k0,fillOut:ln}}t.default=A,e.exports=t.default})(de,de.exports)),de.exports}var be={exports:{}},$s;function Pc(){return $s||($s=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=ue(),n=Math,o=n.sqrt,i=n.pow,s=n.exp,a=n.log,c=n.cos,u=n.sin,f=n.atan2,l={LCD:{K_L:.77,c_1:.007,c_2:.0053},SCD:{K_L:1.24,c_1:.007,c_2:.0363},UCS:{K_L:1,c_1:.007,c_2:.0228}};function h(){var d=arguments.length<=0||arguments[0]===void 0?"UCS":arguments[0],b=l[d],g=b.K_L,m=b.c_1,y=b.c_2;function L(S){var R=S.J,M=S.M,p=S.h,C=r.degree.toRadian(p),O=(1+100*m)*R/(1+m*R),q=1/y*a(1+y*M),T=q*c(C),A=q*u(C);return{J_p:O,a_p:T,b_p:A}}function w(S){var R=S.J_p,M=S.a_p,p=S.b_p,C=-R/(m*R-100*m-1),O=o(i(M,2)+i(p,2)),q=(s(y*O)-1)/y,T=f(p,M),A=r.degree.fromRadian(T);return{J:C,M:q,h:A}}function x(S,R){return o(i((S.J_p-R.J_p)/g,2)+i(S.a_p-R.a_p,2)+i(S.b_p-R.b_p,2))}return{fromCam:L,toCam:w,distance:x}}t.default=h,e.exports=t.default})(be,be.exports)),be.exports}var tn,Es;function zc(){if(Es)return tn;Es=1;var e=fe(),t=Tc(),r=jc(),n=Pc(),o=As();return tn={gamut:t,cfs:e.cfs,lerp:e.lerp,cam:r,ucs:n,hq:o},tn}var Bc=zc();const Ls=to(Bc);function Ns(e){const t=new v;return t.rgb_r=e[0],t.rgb_g=e[1],t.rgb_b=e[2],t.rgbToHsluv(),[t.hsluv_h,t.hsluv_s,t.hsluv_l]}function Ic(e){const t=new v;return t.hsluv_h=e[0],t.hsluv_s=e[1],t.hsluv_l=e[2],t.hsluvToRgb(),[t.rgb_r,t.rgb_g,t.rgb_b]}const Ts=Ls.cam({whitePoint:le.illuminant.D65,adaptingLuminance:40,backgroundLuminance:20,surroundType:"average",discounting:!1},Ls.cfs("JCh")),js=le.xyz(le.workspace.sRGB,le.illuminant.D65),Ps=e=>js.toRgb(Ts.toXyz({J:e[0],C:e[1],h:e[2]})),en=e=>{const t=Ts.fromXyz(js.fromRgb(e));return[t.J,t.C,t.h]},[Gc,Fc]=(()=>{const e={k_l:1,c1:.007,c2:.0228},t=Math.PI,r=64/t/5,n=1/(5*r+1),o=.2*n**4*(5*r)+.1*(1-n**4)**2*(5*r)**(1/3);return[i=>{const[s,a,c]=i,u=a*o**.25;let f=(1+100*e.c1)*s/(1+e.c1*s);f/=e.k_l;const l=1/e.c2*Math.log(1+e.c2*u),h=l*Math.cos(c*(t/180)),d=l*Math.sin(c*(t/180));return[f,h,d]},i=>{const[s,a,c]=i,u=Math.sqrt(a*a+c*c),f=(Math.exp(u*e.c2)-1)/e.c2,l=(180/t*Math.atan2(c,a)+360)%360,h=f/o**.25;return[s/(1+e.c1*(100-s)),h,l]}]})(),Kc=e=>Ps(Fc(e)),zs=e=>Gc(en(e)),pe=console;pe.color=(e,t="")=>{const n=k(e).luminance();pe.log(`%c${e} ${t}`,`background-color: ${e};padding: 5px; border-radius: 5px; color: ${n>.5?"#000":"#fff"}`)},pe.ramp=(e,t=1)=>{pe.log("%c ",`font-size: 1px;line-height: 16px;background: ${k.getCSSGradient(e,t)};padding: 0 0 0 200px; border-radius: 2px;`)};const Bs=(e,t,r,n,o,i,s=.1)=>{if(e===r||t===n)return!0;const a=(n-t)/(r-e),c=(i+o/a-t+a*e)/(a+1/a),u=i+o/a-c/a;return(o-c)**2+(i-u)**2{const o=(t[0]+r[0])/2,i=e(o);return Bs(...t,...r,o,i,n)?null:[o,i]},rn=(e,t,r,n=.1)=>{const o=(r-t)/10,i=[];for(let s=t;sMath.round(e*10**t)/10**t,Dc=(e,t=1,r=90,n=.005)=>{const o=rn(c=>e(c).gl()[0],0,t,n),i=rn(c=>e(c).gl()[1],0,t,n),s=rn(c=>e(c).gl()[2],0,t,n),a=Array.from(new Set([...o.map(c=>me(c[0])),...i.map(c=>me(c[0])),...s.map(c=>me(c[0]))].sort((c,u)=>c-u)));return`linear-gradient(${r}deg, ${a.map(c=>`${e(c).hex()} ${me(c*100)}%`).join()});`},Vc=e=>{e.Color.prototype.jch=function(){return en(this._rgb.slice(0,3).map(o=>o/255))},e.jch=(...o)=>new e.Color(...Ps(o).map(i=>Math.floor(i*255)),"rgb"),e.Color.prototype.jab=function(){return zs(this._rgb.slice(0,3).map(o=>o/255))},e.jab=(...o)=>new e.Color(...Kc(o).map(i=>Math.floor(i*255)),"rgb"),e.Color.prototype.hsluv=function(){return Ns(this._rgb.slice(0,3).map(o=>o/255))},e.hsluv=(...o)=>new e.Color(...Ic(o).map(i=>Math.floor(i*255)),"rgb");const t=e.interpolate,r={jch:en,jab:zs,hsluv:Ns},n=(o,i,s)=>(Math.abs(o-i)>360/2&&(o>i?i+=360:o+=360),((1-s)*o+s*i)%360);e.interpolate=(o,i,s=.5,a="lrgb")=>{if(r[a]){typeof o!="object"&&(o=new e.Color(o)),typeof i!="object"&&(i=new e.Color(i));const c=r[a](o.gl()),u=r[a](i.gl()),f=Number.isNaN(o.hsl()[0]),l=Number.isNaN(i.hsl()[0]);let h,d,b;switch(a){case"hsluv":c[1]<1e-10&&(c[0]=u[0]),c[1]===0&&(c[1]=u[1]),u[1]<1e-10&&(u[0]=c[0]),u[1]===0&&(u[1]=c[1]),h=n(c[0],u[0],s),d=c[1]+(u[1]-c[1])*s,b=c[2]+(u[2]-c[2])*s;break;case"jch":f&&(c[2]=u[2]),l&&(u[2]=c[2]),h=c[0]+(u[0]-c[0])*s,d=c[1]+(u[1]-c[1])*s,b=n(c[2],u[2],s);break;default:h=c[0]+(u[0]-c[0])*s,d=c[1]+(u[1]-c[1])*s,b=c[2]+(u[2]-c[2])*s}return e[a](h,d,b).alpha(o.alpha()+s*(i.alpha()-o.alpha()))}return t(o,i,s,a)},e.getCSSGradient=Dc};const X={mainTRC:2.4,sRco:.2126729,sGco:.7151522,sBco:.072175,normBG:.56,normTXT:.57,revTXT:.62,revBG:.65,blkThrs:.022,blkClmp:1.414,scaleBoW:1.14,scaleWoB:1.14,loBoWoffset:.027,loWoBoffset:.027,deltaYmin:5e-4,loClip:.1};function Is(e,t,r=-1){const n=[0,1.1];if(isNaN(e)||isNaN(t)||Math.min(e,t)n[1])return 0;let o=0,i=0,s="BoW";return e=e>X.blkThrs?e:e+Math.pow(X.blkThrs-e,X.blkClmp),t=t>X.blkThrs?t:t+Math.pow(X.blkThrs-t,X.blkClmp),Math.abs(t-e)e?(o=(Math.pow(t,X.normBG)-Math.pow(e,X.normTXT))*X.scaleBoW,i=o-.1?0:o+X.loWoBoffset),r<0?i*100:r==0?Math.round(Math.abs(i)*100)+""+s+"":Number.isInteger(r)?(i*100).toFixed(r):0)}function ge(e=[0,0,0]){function t(r){return Math.pow(r/255,X.mainTRC)}return X.sRco*t(e[0])+X.sGco*t(e[1])+X.sBco*t(e[2])}const Gs=(e,t,r,n,o,i,s,a,c)=>{const u=1-c,f=u*u,l=f*u,d=c*c*c,b=l*e+f*3*c*r+u*3*c*c*o+d*s,g=l*t+f*3*c*n+u*3*c*c*i+d*a;return{x:b,y:g}},Yc=(e,t)=>{const r=[];let n={x:+e[0],y:+e[1]};for(let o=0,i=e.length;i-2*!0>o;o+=2){const s=[{x:+e[o-2],y:+e[o-1]},{x:+e[o],y:+e[o+1]},{x:+e[o+2],y:+e[o+3]},{x:+e[o+4],y:+e[o+5]}];i-4===o?s[3]=s[2]:o||(s[0]={x:+e[o],y:+e[o+1]}),r.push([n.x,n.y,(-s[0].x+6*s[1].x+s[2].x)/6,(-s[0].y+6*s[1].y+s[2].y)/6,(s[1].x+6*s[2].x-s[3].x)/6,(s[1].y+6*s[2].y-s[3].y)/6,s[2].x,s[2].y]),n=s[2]}return r},Zc=(e,t,r,n,o,i,s,a)=>{let u=e,f=t,l=0;for(let h=1;h<5;h++){const{x:d,y:b}=Gs(e,t,r,n,o,i,s,a,h/5);l+=Math.hypot(d-u,b-f),u=d,f=b}return l+=Math.hypot(s-u,a-f),l},Jc=(e,t,r,n,o,i,s,a)=>{const c=Math.floor(Zc(e,t,r,n,o,i,s,a)*.75),u=[];let f=0;for(let l=0;l<=c;l++){const h=l/c,d=Gs(e,t,r,n,o,i,s,a,h),b=Math.round(d.x);if(u[b]=d.y,b-f>1){const g=u[f],m=u[b];for(let y=f+1;yu[Math.round(l)]||null},Gt={CAM02:"jab",CAM02p:"jch",HEX:"hex",HSL:"hsl",HSLuv:"hsluv",HSV:"hsv",LAB:"lab",LCH:"lch",RGB:"rgb",OKLAB:"oklab",OKLCH:"oklch"};function wt(e,t=0){const r=10**t;return Math.round(e*r)/r}function Hc(e,t){let r;return e>1?r=(e-1)*t+1:e<-1?r=(e+1)*t-1:r=1,wt(r,2)}function Wc(e){return k(String(e)).jch()}function Uc(e){return k(String(e)).hsluv()}function Qc(e,t,r){const n=[[],[],[]];if(e.forEach((i,s)=>n.forEach((a,c)=>a.push(t[s],i[c]))),r==="hcl"){const i=n[1];for(let s=1;s{const s=[];for(let a=1;a{i[c]=i[a]}),s.length=0;break}if(s.length){const a=k("#ccc").jch()[2];s.forEach(c=>{i[c]=a})}s.length=0;for(let a=i.length-1;a>0;a-=2)if(Number.isNaN(i[a]))s.push(a);else{s.forEach(c=>{i[c]=i[a]});break}for(let a=1;aYc(i).map(s=>Jc(...s)));return i=>{const s=o.map(a=>{for(let c=0;cn*i**e+o}function nn({swatches:e,colorKeys:t,colorspace:r,colorSpace:n=r??"LAB",shift:o=1,fullScale:i=!0,smooth:s=!1,distributeLightness:a="linear",sortColor:c=!0,asFun:u=!1}={}){r!==void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead.");const f=Gt[n];if(!f)throw new Error(`Colorspace “${n}” not supported`);if(!t)throw new Error(`Colorkeys missing: returned “${t}”`);let l;if(i)l=t.map(w=>e-e*(k(w).jch()[0]/100)).sort((w,x)=>w-x).concat(e),l.unshift(0);else{let w=t.map(R=>k(R).jch()[0]/100),x=Math.min(...w),S=Math.max(...w);l=w.map(R=>R===0||isNaN((R-x)/(S-x))?0:e-(R-x)/(S-x)*e).sort((R,M)=>R-M)}let h=t0(o,[1,e],[1,e]);if(h=l.map(w=>Math.max(0,h(w))),l=h,a==="polynomial"){const w=R=>Math.sqrt(Math.sqrt((Math.pow(R,2.25)+Math.pow(R,4))/2));l=h.map(R=>R/e).map(R=>w(R)*e)}const d=t.map((w,x)=>({colorKeys:Wc(w),index:x})).sort((w,x)=>x.colorKeys[0]-w.colorKeys[0]).map(w=>t[w.index]);let b=[],g;if(i){const w=f==="lch"?k.lch(...k("#fff").lch()):"#ffffff",x=f==="lch"?k.lch(...k("#000").lch()):"#000000";b=[w,...d,x]}else c?b=d:b=t;let m;if(s){const w=b;if(b=b.map(x=>k(String(x))[f]()),f==="hcl"&&b.forEach(x=>{x[1]=Number.isNaN(x[1])?0:x[1]}),f==="jch")for(let x=0;xg(S))}else g=k.scale(b.map(w=>typeof w=="object"&&w.constructor===k.Color?w:String(w))).domain(l).mode(f);return u?g:(!s||s===!1?g.colors(e):m).filter(w=>w!=null)}function e0(e,t){const r=[],n={};return Object.keys(e).forEach(s=>{n[e[s][t]]=e[s]}),Object.keys(n).forEach(s=>r.push(n[s])),r}function r0(e){return Number.isNaN(e)?0:e}function on(e,t,r=!1){if(!e)throw new Error(`Cannot convert color value of “${e}”`);if(!Gt[t])throw new Error(`Cannot convert to colorspace “${t}”`);const n=Gt[t],o=k(String(e))[n]();if(t==="HSL"&&o.pop(),t==="HEX"){if(r){const u=k(String(e)).rgb();return{r:u[0],g:u[1],b:u[2]}}return o}const i={};let s=o.map(r0);s=s.map((u,f)=>{let l=wt(u),h=f;n==="hsluv"&&(h+=2);let d=n.charAt(h);return n==="jch"&&d==="c"&&(d="C"),i[d==="j"?"J":d]=l,n in{lab:1,lch:1,jab:1,jch:1}?r||(d==="l"||d==="j")&&(l+="%"):n!=="hsluv"&&(d==="s"||d==="l"||d==="v")&&(i[d]=wt(u,2),r||(l=wt(u*100),l+="%")),l});const c=`${n}(${s.join(", ")})`;return r?i:c}function Fs(e,t,r){const n=[e,t,r].map(o=>(o/=255,o<=.03928?o/12.92:((o+.055)/1.055)**2.4));return n[0]*.2126+n[1]*.7152+n[2]*.0722}function n0(e,t,r,n="wcag2"){if(r===void 0){const o=k.rgb(...t).hsluv()[2];r=wt(o/100,2)}if(n==="wcag2"){const o=Fs(e[0],e[1],e[2]),i=Fs(t[0],t[1],t[2]),s=(o+.05)/(i+.05),a=(i+.05)/(o+.05);return r<.5?s>=1?s:-a:s<1?a:s===1?s:-s}else{if(n==="wcag3")return r<.5?Is(ge(e),ge(t))*-1:Is(ge(e),ge(t));throw new Error(`Contrast calculation method ${n} unsupported; use 'wcag2' or 'wcag3'`)}}function o0(e,t){if(!e)throw new Error("Array undefined");if(!Array.isArray(e))throw new Error("Passed object is not an array");const r=t==="wcag2"?0:1;return Math.min(...e.filter(n=>n>=r))}function s0(e,t){if(!e)throw new Error("Ratios undefined");e=e.sort((a,c)=>a-c);const r=o0(e,t),n=e.indexOf(r),o=[],i=e.slice(0,n),s=e.slice(n,e.length);for(let a=0;aa-c),o}const i0=(e,t,r,n,o)=>{const s=nn({swatches:3e3,colorKeys:e._modifiedKeys,colorspace:e._colorspace,shift:1,smooth:e._smooth,asFun:!0}),a={},c=l=>{if(a[l])return a[l];const h=k(s(l)).rgb(),d=n0(h,t,r,o);return a[l]=d,d},u=l=>{const h=c(0),d=c(3e3),b=hg&&w;)w--,m/=2,Lf.push(s(u(+l)))),f};class Q{constructor({name:t,colorKeys:r,colorspace:n,colorSpace:o=n??"RGB",ratios:i,smooth:s=!1,output:a="HEX",saturation:c=100}){if(n!==void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),this._name=t,this._colorKeys=r,this._modifiedKeys=r,this._colorspace=o,this._ratios=i,this._smooth=s,this._output=a,this._saturation=c,!this._name)throw new Error("Color missing name");if(!this._colorKeys)throw new Error("Color Keys are undefined");if(!Gt[this._colorspace])throw new Error(`Colorspace “${o}” not supported`);if(!Gt[this._output])throw new Error(`Output “${this._output}” not supported`);for(let u=0;u{let n=k(`${r}`).oklch(),i=n[1]*(this._saturation/100),s=k.oklch(n[0],i,n[2]),a=k.rgb(s).hex();t.push(a)}),this._modifiedKeys=t,this._generateColorScale()}_generateColorScale(){this._colorScale=nn({swatches:3e3,colorKeys:this._modifiedKeys,colorSpace:this._colorspace,shift:1,smooth:this._smooth,asFun:!0})}}class Ks extends Q{get backgroundColorScale(){return this._backgroundColorScale||this._generateColorScale(),this._backgroundColorScale}_generateColorScale(){Q.prototype._generateColorScale.call(this);const t=nn({swatches:1e3,colorKeys:this._colorKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth});t.push(...this.colorKeys);const r=t.map((i,s)=>({value:Math.round(Uc(i)[2]),index:s})),o=e0(r,"value").map(i=>t[i.index]);return o.length>=101&&(o.length=100,o.push("#ffffff")),this._backgroundColorScale=o.map(i=>on(i,this._output)),this._backgroundColorScale}}class a0{constructor({colors:t,backgroundColor:r,lightness:n,contrast:o=1,saturation:i=100,output:s="HEX",formula:a="wcag2"}){if(this._output=s,this._colors=t,this._lightness=n,this._saturation=i,this._formula=a,this._setBackgroundColor(r),this._setBackgroundColorValue(),this._contrast=o,!this._colors)throw new Error("No colors are defined");if(!this._backgroundColor)throw new Error("Background color is undefined");if(t.forEach(c=>{if(!c.ratios)throw new Error(`Color ${c.name}'s ratios are undefined`)}),!Gt[this._output])throw new Error(`Output “${s}” not supported`);this._saturation<100&&this._updateColorSaturation(this._saturation),this._findContrastColors(),this._findContrastColorPairs(),this._findContrastColorValues()}set formula(t){this._formula=t,this._findContrastColors()}get formula(){return this._formula}set contrast(t){this._contrast=t,this._findContrastColors()}get contrast(){return this._contrast}set lightness(t){this._lightness=t,this._setBackgroundColor(this._backgroundColor),this._findContrastColors()}get lightness(){return this._lightness}set saturation(t){this._saturation=t,this._updateColorSaturation(t),this._findContrastColors()}get saturation(){return this._saturation}set backgroundColor(t){this._setBackgroundColor(t),this._findContrastColors()}get backgroundColorValue(){return this._backgroundColorValue}get backgroundColor(){return this._backgroundColor}set colors(t){this._colors=t,this._findContrastColors()}get colors(){return this._colors}set addColor(t){this._colors.push(t),this._findContrastColors()}set removeColor(t){const r=this._colors.filter(n=>n.name!==t.name);this._colors=r,this._findContrastColors()}set updateColor(t){if(Array.isArray(t))for(let r=0;rs.name===t[r].color);n=n[0];let o=this._colors.indexOf(n);const i=this._colors.filter(s=>s.name!==t[r].color);t[r].name&&(n.name=t[r].name),t[r].colorKeys&&(n.colorKeys=t[r].colorKeys),t[r].ratios&&(n.ratios=t[r].ratios),(t[r].colorSpace!==void 0||t[r].colorspace!==void 0)&&(t[r].colorspace!==void 0&&t[r].colorSpace===void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),n.colorSpace=t[r].colorSpace??t[r].colorspace),t[r].smooth&&(n.smooth=t[r].smooth),n._generateColorScale(),i.splice(o,0,n),this._colors=i}else{let r=this._colors.filter(i=>i.name===t.color);r=r[0];let n=this._colors.indexOf(r);const o=this._colors.filter(i=>i.name!==t.color);t.name&&(r.name=t.name),t.colorKeys&&(r.colorKeys=t.colorKeys),t.ratios&&(r.ratios=t.ratios),(t.colorSpace!==void 0||t.colorspace!==void 0)&&(t.colorspace!==void 0&&t.colorSpace===void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),r.colorSpace=t.colorSpace??t.colorspace),t.smooth&&(r.smooth=t.smooth),r._generateColorScale(),o.splice(n,0,r),this._colors=o}this._findContrastColors()}set output(t){this._output=t,this._colors.forEach(r=>{r.output=this._output}),this._backgroundColor.output=this._output,this._findContrastColors()}get output(){return this._output}get contrastColors(){return this._contrastColors}get contrastColorPairs(){return this._contrastColorPairs}get contrastColorValues(){return this._contrastColorValues}_setBackgroundColor(t){if(typeof t=="string"){const r=new Ks({name:"background",colorKeys:[t],output:"RGB"}),n=wt(k(String(t)).hsluv()[2]);this._backgroundColor=r,this._lightness=n,this._backgroundColorValue=r[this._lightness]}else{t.output="RGB";const r=t.backgroundColorScale[this._lightness];this._backgroundColor=t,this._backgroundColorValue=r}}_setBackgroundColorValue(){this._backgroundColorValue=this._backgroundColor.backgroundColorScale[this._lightness]}_updateColorSaturation(t){this._colors.map(r=>{r.saturation=t})}_findContrastColors(){const t=k(String(this._backgroundColorValue)).rgb(),r=this._lightness/100,o={background:on(this._backgroundColorValue,this._output)},i=[],s=[],a={...o};return i.push(o),this._colors.map(c=>{if(c.ratios!==void 0){let u;const f=[],l={name:c.name,values:f};let h;Array.isArray(c.ratios)?h=c.ratios:Array.isArray(c.ratios)||(u=Object.keys(c.ratios),h=Object.values(c.ratios)),h=h.map(b=>Hc(+b,this._contrast));const d=i0(c,t,r,h,this._formula).map(b=>on(b,this._output));for(let b=0;b{const t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return[Number.parseInt(t[1],16),Number.parseInt(t[2],16),Number.parseInt(t[3],16)]},Ds=(e,t,r)=>{const n=e/255,o=t/255,i=r/255,s=Math.min(n,o,i),a=Math.max(n,o,i),c=a-s;let u=0,f=0,l=0;return c===0?u=0:a===n?u=(o-i)/c%6:a===o?u=(i-n)/c+2:u=(n-o)/c+4,u=Math.round(u*60),u<0&&(u+=360),l=(a+s)/2,f=c===0?0:c/(1-Math.abs(2*l-1)),f=+(f*100).toFixed(1),l=+(l*100).toFixed(1),[u,f,Math.round(l)]},u0=(e,t,r,n)=>{const o=r/100,i=t*Math.min(o,1-o)/100,s=d=>{const b=(d+e/30)%12,g=o-i*Math.max(Math.min(b-3,9-b,1),-1);return Math.round(255*g).toString(16).padStart(2,"0").toUpperCase()},a=s(0),c=s(8),u=s(4),l=((d,b,g)=>Math.min(Math.max(d,b),g))(n,0,1),h=Math.round(l*255).toString(16).padStart(2,"0").toUpperCase();return`#${a}${c}${u}${h}`},l0=(e,t,r=1)=>{const n=Xs(e),o=Xs(t==="white"?"#FFFFFF":t==="black"?"#000000":t),i=n.map((u,f)=>[(u-o[f])/(255-o[f]),(u-o[f])/(0-o[f])]),s=c0(Math.max(...i.flat().filter(u=>/^-?\d+\.?\d*$/.test(u)))),a=n.map((u,f)=>Math.round((u-o[f]+o[f]*s)/s));if(a.includes(Number.NaN)){const u=Ds(n[0],n[1],n[2]);return{h:u[0],s:Math.round(u[1]*r),l:u[2],a:1}}const c=Ds(a[0],a[1],a[2]);return{h:c[0],s:Math.round(c[1]*r),l:c[2],a:s}},sn={backgroundColor:"gray",colorSpace:"OKLCH",colorSmoothing:!1,formula:"wcag2",output:"HEX",colors:{gray:[I(215,20,90),I(215,8,50),I(215,6,25)],red:[I(358,100,58),I(350,100,30)],orange:[I(32,100,48),I(12,100,30)],yellow:[I(50,100,50),I(25,100,20)],lime:[I(100,68,50),I(115,86,25)],green:[I(163,87,42),I(168,100,25)],cyan:[I(185,80,45),I(200,98,35)],blue:[I(212,98,46),I(222,95,25)],purple:[I(258,94,64),I(265,100,35)],fuchsia:[I(295,56,50),I(285,80,25)],pink:[I(334,90,50),I(330,91,25)]},themes:{light:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16.75],contrast:1,lightness:100,saturation:100},dark:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16],contrast:1,lightness:6,saturation:97},lightHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:100,saturation:100},darkHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:6,saturation:97}}};function I(e,t,r){return k.hsl(e,t/100,r/100).hex()}function f0(e,t){const r=e.colorSpace,n=e.colorSmoothing,o=e.themes[t].ratios,i=new Ks({name:"gray",colorKeys:e.colors.gray,colorspace:r,ratios:o,smooth:n}),s=new Q({name:"blue",colorKeys:e.colors.blue,colorspace:r,ratios:o,smooth:n}),a=new Q({name:"cyan",colorKeys:e.colors.cyan,colorspace:r,ratios:o,smooth:n}),c=new Q({name:"fuchsia",colorKeys:e.colors.fuchsia,colorspace:r,ratios:o,smooth:n}),u=new Q({name:"green",colorKeys:e.colors.green,colorspace:r,ratios:o,smooth:n}),f=new Q({name:"lime",colorKeys:e.colors.lime,colorspace:r,ratios:o,smooth:n}),l=new Q({name:"orange",colorKeys:e.colors.orange,colorspace:r,ratios:o,smooth:n}),h=new Q({name:"pink",colorKeys:e.colors.pink,colorspace:r,ratios:o,smooth:n}),d=new Q({name:"purple",colorKeys:e.colors.purple,colorspace:r,ratios:o,smooth:n}),b=new Q({name:"red",colorKeys:e.colors.red,colorspace:r,ratios:o,smooth:n}),g=new Q({name:"yellow",colorKeys:e.colors.yellow,colorspace:r,ratios:o,smooth:n}),m={gray:i,red:b,orange:l,yellow:g,lime:f,green:u,cyan:a,blue:s,purple:d,fuchsia:c,pink:h};return e.colors.custom&&(m.custom=new Q({name:"custom",colorKeys:e.colors.custom,colorspace:r,ratios:o,smooth:n})),new a0({colors:Object.values(m),backgroundColor:m[e.backgroundColor],contrast:e.themes[t].contrast,lightness:e.themes[t].lightness,saturation:e.themes[t].saturation,output:e.output,formula:e.formula}).contrastColors}function Vs(e){const t={};for(const r of Object.keys(e.themes))t[r]=f0(e,r);return t}function h0(e){sn.colors.custom=[e];const t=Vs(sn);return Object.fromEntries(Object.entries(t).map(([r,n])=>{const o=n.find(s=>s&&s.name==="custom"),i=Object.fromEntries(o.values.map(({name:s,value:a})=>[s,a]));for(const[s,a]of Object.entries(i)){const c=l0(a,n[0].background);i[`alpha${s.charAt(0).toUpperCase()+s.slice(1)}`]=u0(c.h,c.s,c.l,c.a)}return[r,i]}))}return $t.generateCustomColors=h0,$t.generateThemesJson=Vs,$t.hslToHex=I,$t.leonardoConfig=sn,Object.defineProperty($t,Symbol.toStringTag,{value:"Module"}),$t})({}); diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt index 6c30eda7f9..456ffcf625 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -25,6 +25,9 @@ object CompoundIcons { @Composable fun Admin(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_admin) } + @Composable fun AdvancedSettings(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_advanced_settings) + } @Composable fun ArrowDown(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down) } @@ -64,6 +67,9 @@ object CompoundIcons { @Composable fun Bold(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_bold) } + @Composable fun Bug(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_bug) + } @Composable fun Calendar(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_calendar) } @@ -460,6 +466,9 @@ object CompoundIcons { @Composable fun RaisedHandSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_raised_hand_solid) } + @Composable fun ReOrder(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_re_order) + } @Composable fun Reaction(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_reaction) } @@ -478,9 +487,18 @@ object CompoundIcons { @Composable fun Room(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_room) } + @Composable fun RotateLeft(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_rotate_left) + } + @Composable fun RotateRight(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_rotate_right) + } @Composable fun Search(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_search) } + @Composable fun Section(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_section) + } @Composable fun Send(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_send) } @@ -535,6 +553,12 @@ object CompoundIcons { @Composable fun Sticker(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_sticker) } + @Composable fun Stop(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_stop) + } + @Composable fun StopSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_stop_solid) + } @Composable fun Strikethrough(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough) } @@ -550,6 +574,9 @@ object CompoundIcons { @Composable fun TextFormatting(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_text_formatting) } + @Composable fun Theme(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_theme) + } @Composable fun Threads(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_threads) } @@ -559,6 +586,12 @@ object CompoundIcons { @Composable fun Time(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_time) } + @Composable fun Translate(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_translate) + } + @Composable fun Tree(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_tree) + } @Composable fun Underline(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_underline) } @@ -607,6 +640,9 @@ object CompoundIcons { @Composable fun VideoCallOffSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid) } + @Composable fun VideoCallOutgoingSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_outgoing_solid) + } @Composable fun VideoCallSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid) } @@ -619,6 +655,15 @@ object CompoundIcons { @Composable fun VoiceCall(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_voice_call) } + @Composable fun VoiceCallDeclinedSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_declined_solid) + } + @Composable fun VoiceCallMissedSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_missed_solid) + } + @Composable fun VoiceCallOutgoingSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_outgoing_solid) + } @Composable fun VoiceCallSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid) } @@ -643,9 +688,16 @@ object CompoundIcons { @Composable fun Windows(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_windows) } + @Composable fun ZoomIn(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_zoom_in) + } + @Composable fun ZoomOut(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_zoom_out) + } val all @Composable get() = persistentListOf( Admin(), + AdvancedSettings(), ArrowDown(), ArrowLeft(), ArrowRight(), @@ -659,6 +711,7 @@ object CompoundIcons { BackspaceSolid(), Block(), Bold(), + Bug(), Calendar(), Chart(), Chat(), @@ -791,13 +844,17 @@ object CompoundIcons { QrCode(), Quote(), RaisedHandSolid(), + ReOrder(), Reaction(), ReactionAdd(), ReactionSolid(), Reply(), Restart(), Room(), + RotateLeft(), + RotateRight(), Search(), + Section(), Send(), SendSolid(), Settings(), @@ -816,14 +873,19 @@ object CompoundIcons { Spotlight(), SpotlightView(), Sticker(), + Stop(), + StopSolid(), Strikethrough(), SwitchCameraSolid(), TakePhoto(), TakePhotoSolid(), TextFormatting(), + Theme(), Threads(), ThreadsSolid(), Time(), + Translate(), + Tree(), Underline(), Unknown(), UnknownSolid(), @@ -840,10 +902,14 @@ object CompoundIcons { VideoCallMissedSolid(), VideoCallOff(), VideoCallOffSolid(), + VideoCallOutgoingSolid(), VideoCallSolid(), VisibilityOff(), VisibilityOn(), VoiceCall(), + VoiceCallDeclinedSolid(), + VoiceCallMissedSolid(), + VoiceCallOutgoingSolid(), VoiceCallSolid(), VolumeOff(), VolumeOffSolid(), @@ -852,10 +918,13 @@ object CompoundIcons { Warning(), WebBrowser(), Windows(), + ZoomIn(), + ZoomOut(), ) val allResIds get() = persistentListOf( R.drawable.ic_compound_admin, + R.drawable.ic_compound_advanced_settings, R.drawable.ic_compound_arrow_down, R.drawable.ic_compound_arrow_left, R.drawable.ic_compound_arrow_right, @@ -869,6 +938,7 @@ object CompoundIcons { R.drawable.ic_compound_backspace_solid, R.drawable.ic_compound_block, R.drawable.ic_compound_bold, + R.drawable.ic_compound_bug, R.drawable.ic_compound_calendar, R.drawable.ic_compound_chart, R.drawable.ic_compound_chat, @@ -1001,13 +1071,17 @@ object CompoundIcons { R.drawable.ic_compound_qr_code, R.drawable.ic_compound_quote, R.drawable.ic_compound_raised_hand_solid, + R.drawable.ic_compound_re_order, R.drawable.ic_compound_reaction, R.drawable.ic_compound_reaction_add, R.drawable.ic_compound_reaction_solid, R.drawable.ic_compound_reply, R.drawable.ic_compound_restart, R.drawable.ic_compound_room, + R.drawable.ic_compound_rotate_left, + R.drawable.ic_compound_rotate_right, R.drawable.ic_compound_search, + R.drawable.ic_compound_section, R.drawable.ic_compound_send, R.drawable.ic_compound_send_solid, R.drawable.ic_compound_settings, @@ -1026,14 +1100,19 @@ object CompoundIcons { R.drawable.ic_compound_spotlight, R.drawable.ic_compound_spotlight_view, R.drawable.ic_compound_sticker, + R.drawable.ic_compound_stop, + R.drawable.ic_compound_stop_solid, R.drawable.ic_compound_strikethrough, R.drawable.ic_compound_switch_camera_solid, R.drawable.ic_compound_take_photo, R.drawable.ic_compound_take_photo_solid, R.drawable.ic_compound_text_formatting, + R.drawable.ic_compound_theme, R.drawable.ic_compound_threads, R.drawable.ic_compound_threads_solid, R.drawable.ic_compound_time, + R.drawable.ic_compound_translate, + R.drawable.ic_compound_tree, R.drawable.ic_compound_underline, R.drawable.ic_compound_unknown, R.drawable.ic_compound_unknown_solid, @@ -1050,10 +1129,14 @@ object CompoundIcons { R.drawable.ic_compound_video_call_missed_solid, R.drawable.ic_compound_video_call_off, R.drawable.ic_compound_video_call_off_solid, + R.drawable.ic_compound_video_call_outgoing_solid, R.drawable.ic_compound_video_call_solid, R.drawable.ic_compound_visibility_off, R.drawable.ic_compound_visibility_on, R.drawable.ic_compound_voice_call, + R.drawable.ic_compound_voice_call_declined_solid, + R.drawable.ic_compound_voice_call_missed_solid, + R.drawable.ic_compound_voice_call_outgoing_solid, R.drawable.ic_compound_voice_call_solid, R.drawable.ic_compound_volume_off, R.drawable.ic_compound_volume_off_solid, @@ -1062,5 +1145,7 @@ object CompoundIcons { R.drawable.ic_compound_warning, R.drawable.ic_compound_web_browser, R.drawable.ic_compound_windows, + R.drawable.ic_compound_zoom_in, + R.drawable.ic_compound_zoom_out, ) } diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt index 6136b04d4b..4c9b20ef9f 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -121,18 +121,14 @@ data class SemanticColors( val gradientActionStop3: Color, /** Background gradient stop for super and send buttons */ val gradientActionStop4: Color, + /** Subtle background gradient stop for critical */ + val gradientCriticalStop1: Color, + /** Subtle background gradient stop for critical */ + val gradientCriticalStop2: Color, /** Subtle background gradient stop for info */ val gradientInfoStop1: Color, /** Subtle background gradient stop for info */ val gradientInfoStop2: Color, - /** Subtle background gradient stop for info */ - val gradientInfoStop3: Color, - /** Subtle background gradient stop for info */ - val gradientInfoStop4: Color, - /** Subtle background gradient stop for info */ - val gradientInfoStop5: Color, - /** Subtle background gradient stop for info */ - val gradientInfoStop6: Color, /** Subtle background gradient stop for message highlight and bloom */ val gradientSubtleStop1: Color, /** Subtle background gradient stop for message highlight and bloom */ diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt index 60afe79ae2..9ad028f507 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -73,12 +73,10 @@ val compoundColorsDark = SemanticColors( gradientActionStop2 = DarkColorTokens.colorGreen900, gradientActionStop3 = DarkColorTokens.colorGreen700, gradientActionStop4 = DarkColorTokens.colorGreen500, - gradientInfoStop1 = DarkColorTokens.colorAlphaBlue500, - gradientInfoStop2 = DarkColorTokens.colorAlphaBlue400, - gradientInfoStop3 = DarkColorTokens.colorAlphaBlue300, - gradientInfoStop4 = DarkColorTokens.colorAlphaBlue200, - gradientInfoStop5 = DarkColorTokens.colorAlphaBlue100, - gradientInfoStop6 = DarkColorTokens.colorTransparent, + gradientCriticalStop1 = DarkColorTokens.colorRed200, + gradientCriticalStop2 = DarkColorTokens.colorThemeBg, + gradientInfoStop1 = DarkColorTokens.colorBlue200, + gradientInfoStop2 = DarkColorTokens.colorThemeBg, gradientSubtleStop1 = DarkColorTokens.colorAlphaGreen500, gradientSubtleStop2 = DarkColorTokens.colorAlphaGreen400, gradientSubtleStop3 = DarkColorTokens.colorAlphaGreen300, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt index c718caeeeb..9af9edd913 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -73,12 +73,10 @@ val compoundColorsHcDark = SemanticColors( gradientActionStop2 = DarkHcColorTokens.colorGreen900, gradientActionStop3 = DarkHcColorTokens.colorGreen700, gradientActionStop4 = DarkHcColorTokens.colorGreen500, - gradientInfoStop1 = DarkHcColorTokens.colorAlphaBlue500, - gradientInfoStop2 = DarkHcColorTokens.colorAlphaBlue400, - gradientInfoStop3 = DarkHcColorTokens.colorAlphaBlue300, - gradientInfoStop4 = DarkHcColorTokens.colorAlphaBlue200, - gradientInfoStop5 = DarkHcColorTokens.colorAlphaBlue100, - gradientInfoStop6 = DarkHcColorTokens.colorTransparent, + gradientCriticalStop1 = DarkHcColorTokens.colorRed200, + gradientCriticalStop2 = DarkHcColorTokens.colorThemeBg, + gradientInfoStop1 = DarkHcColorTokens.colorBlue200, + gradientInfoStop2 = DarkHcColorTokens.colorThemeBg, gradientSubtleStop1 = DarkHcColorTokens.colorAlphaGreen500, gradientSubtleStop2 = DarkHcColorTokens.colorAlphaGreen400, gradientSubtleStop3 = DarkHcColorTokens.colorAlphaGreen300, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt index 377997ec79..6569f5a676 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -73,12 +73,10 @@ val compoundColorsLight = SemanticColors( gradientActionStop2 = LightColorTokens.colorGreen700, gradientActionStop3 = LightColorTokens.colorGreen900, gradientActionStop4 = LightColorTokens.colorGreen1100, - gradientInfoStop1 = LightColorTokens.colorAlphaBlue500, - gradientInfoStop2 = LightColorTokens.colorAlphaBlue400, - gradientInfoStop3 = LightColorTokens.colorAlphaBlue300, - gradientInfoStop4 = LightColorTokens.colorAlphaBlue200, - gradientInfoStop5 = LightColorTokens.colorAlphaBlue100, - gradientInfoStop6 = LightColorTokens.colorTransparent, + gradientCriticalStop1 = LightColorTokens.colorRed200, + gradientCriticalStop2 = LightColorTokens.colorThemeBg, + gradientInfoStop1 = LightColorTokens.colorBlue200, + gradientInfoStop2 = LightColorTokens.colorThemeBg, gradientSubtleStop1 = LightColorTokens.colorAlphaGreen500, gradientSubtleStop2 = LightColorTokens.colorAlphaGreen400, gradientSubtleStop3 = LightColorTokens.colorAlphaGreen300, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt index b32a0dd9f0..8a8fa44e61 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -73,12 +73,10 @@ val compoundColorsHcLight = SemanticColors( gradientActionStop2 = LightHcColorTokens.colorGreen700, gradientActionStop3 = LightHcColorTokens.colorGreen900, gradientActionStop4 = LightHcColorTokens.colorGreen1100, - gradientInfoStop1 = LightHcColorTokens.colorAlphaBlue500, - gradientInfoStop2 = LightHcColorTokens.colorAlphaBlue400, - gradientInfoStop3 = LightHcColorTokens.colorAlphaBlue300, - gradientInfoStop4 = LightHcColorTokens.colorAlphaBlue200, - gradientInfoStop5 = LightHcColorTokens.colorAlphaBlue100, - gradientInfoStop6 = LightHcColorTokens.colorTransparent, + gradientCriticalStop1 = LightHcColorTokens.colorRed200, + gradientCriticalStop2 = LightHcColorTokens.colorThemeBg, + gradientInfoStop1 = LightHcColorTokens.colorBlue200, + gradientInfoStop2 = LightHcColorTokens.colorThemeBg, gradientSubtleStop1 = LightHcColorTokens.colorAlphaGreen500, gradientSubtleStop2 = LightHcColorTokens.colorAlphaGreen400, gradientSubtleStop3 = LightHcColorTokens.colorAlphaGreen300, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt index 0f450776ef..ad6811b3ec 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt index 04dbf6b9f2..11502de977 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt index 8b2c04a93a..06e5e3450e 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt index c7561c7ce9..e7ac3ecc66 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt index 40c91c5b53..7ab935932b 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml b/libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml new file mode 100644 index 0000000000..a1c61bf419 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml b/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml index 90464cb595..044da0d4b3 100644 --- a/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml +++ b/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml @@ -4,11 +4,11 @@ android:autoMirrored="true" android:viewportWidth="24" android:viewportHeight="24"> - - - - + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml index 27d5805811..b7f454e330 100644 --- a/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml +++ b/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml @@ -5,6 +5,7 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2L7.33,20a2,2 0,0 1,-1.673 -0.902l-3.937,-6a2,2 0,0 1,0 -2.196l3.937,-6A2,2 0,0 1,7.33 4zM16.457,8.457a1,1 0,0 0,-1.414 0L13,10.5l-2.043,-2.043a1,1 0,0 0,-1.414 1.414l2.043,2.043 -2.129,2.129a1,1 0,0 0,1.414 1.414l2.13,-2.129 2.128,2.129a1,1 0,0 0,1.414 -1.414l-2.129,-2.129 2.043,-2.043a1,1 0,0 0,0 -1.414" + android:fillColor="#FF000000" + android:fillType="evenOdd"/> diff --git a/libraries/compound/src/main/res/drawable/ic_compound_bug.xml b/libraries/compound/src/main/res/drawable/ic_compound_bug.xml new file mode 100644 index 0000000000..7ee32ec13c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mac.xml b/libraries/compound/src/main/res/drawable/ic_compound_mac.xml index 47a4d690ef..f802f5fa4e 100644 --- a/libraries/compound/src/main/res/drawable/ic_compound_mac.xml +++ b/libraries/compound/src/main/res/drawable/ic_compound_mac.xml @@ -4,7 +4,7 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/libraries/compound/src/main/res/drawable/ic_compound_re_order.xml b/libraries/compound/src/main/res/drawable/ic_compound_re_order.xml new file mode 100644 index 0000000000..b980906cc7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_re_order.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml b/libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml new file mode 100644 index 0000000000..5d372e143c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml b/libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml new file mode 100644 index 0000000000..0026ea4ab8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_section.xml b/libraries/compound/src/main/res/drawable/ic_compound_section.xml new file mode 100644 index 0000000000..14fe28cac4 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_section.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_stop.xml b/libraries/compound/src/main/res/drawable/ic_compound_stop.xml new file mode 100644 index 0000000000..2724787b6f --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_stop_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_stop_solid.xml new file mode 100644 index 0000000000..820ea4405b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_stop_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_theme.xml b/libraries/compound/src/main/res/drawable/ic_compound_theme.xml new file mode 100644 index 0000000000..99e2927492 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_theme.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_translate.xml b/libraries/compound/src/main/res/drawable/ic_compound_translate.xml new file mode 100644 index 0000000000..13c7a37d28 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_translate.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_tree.xml b/libraries/compound/src/main/res/drawable/ic_compound_tree.xml new file mode 100644 index 0000000000..bc7283dca9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_tree.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml new file mode 100644 index 0000000000..aa5b4e4fd7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml index 258a541751..49860d99c8 100644 --- a/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml @@ -4,12 +4,8 @@ android:autoMirrored="true" android:viewportWidth="24" android:viewportHeight="24"> - - - - + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml new file mode 100644 index 0000000000..8a303785d0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml new file mode 100644 index 0000000000..a6a6e25369 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml new file mode 100644 index 0000000000..a5c8e0a329 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml b/libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml new file mode 100644 index 0000000000..62f7b9cd24 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml b/libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml new file mode 100644 index 0000000000..db334baf5e --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml @@ -0,0 +1,13 @@ + + + + diff --git a/tools/compound/addAutoMirrored.py b/tools/compound/addAutoMirrored.py index 4fd4a64aa0..96e4e6c170 100644 --- a/tools/compound/addAutoMirrored.py +++ b/tools/compound/addAutoMirrored.py @@ -94,9 +94,13 @@ files = [ "ic_compound_video_call_missed_solid.xml", "ic_compound_video_call_off.xml", "ic_compound_video_call_off_solid.xml", + "ic_compound_video_call_outgoing_solid.xml", "ic_compound_video_call_solid.xml", "ic_compound_visibility_off.xml", "ic_compound_voice_call.xml", + "ic_compound_voice_call_declined_solid.xml", + "ic_compound_voice_call_missed_solid.xml", + "ic_compound_voice_call_outgoing_solid.xml", "ic_compound_voice_call_solid.xml", "ic_compound_volume_off.xml", "ic_compound_volume_off_solid.xml", From ab7325a2e739ea4be0578dd004f8d14aac7c90d4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 12:42:45 +0100 Subject: [PATCH 08/20] Use stop icon from Compound. --- .../android/libraries/designsystem/icons/IconsList.kt | 1 - libraries/designsystem/src/main/res/drawable/ic_stop.xml | 9 --------- .../components/VoiceMessageRecorderButtonIcon.kt | 2 +- 3 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 libraries/designsystem/src/main/res/drawable/ic_stop.xml diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt index f0a6fc847b..939dfaa69f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.designsystem.R // All the icons should be defined in Compound. internal val iconsOther = listOf( R.drawable.ic_notification, - R.drawable.ic_stop, R.drawable.pin, R.drawable.ic_winner, ) diff --git a/libraries/designsystem/src/main/res/drawable/ic_stop.xml b/libraries/designsystem/src/main/res/drawable/ic_stop.xml deleted file mode 100644 index e4cd1507bb..0000000000 --- a/libraries/designsystem/src/main/res/drawable/ic_stop.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt index aeeaf839c3..f11bb1d5b7 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt @@ -65,7 +65,7 @@ private fun StopButton( ) { Icon( modifier = Modifier.size(24.dp), - resourceId = CommonDrawables.ic_stop, + imageVector = CompoundIcons.StopSolid(), // Note: accessibility is managed in TextComposer. contentDescription = null, tint = ElementTheme.colors.iconOnSolidPrimary, From 2e5304ba7804ef783266202618977be7f896f706 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 12:44:58 +0100 Subject: [PATCH 09/20] Fix compilation issue. --- .../compound/previews/SemanticColorsPreview.kt | 6 ++---- .../android/libraries/designsystem/colors/Gradient.kt | 11 +++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt index 3f5fb42149..c629e29866 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt @@ -155,12 +155,10 @@ private fun getSemanticColors(): ImmutableMap { "gradientActionStop2" to gradientActionStop2, "gradientActionStop3" to gradientActionStop3, "gradientActionStop4" to gradientActionStop4, + "gradientCriticalStop1" to gradientCriticalStop1, + "gradientCriticalStop2" to gradientCriticalStop2, "gradientInfoStop1" to gradientInfoStop1, "gradientInfoStop2" to gradientInfoStop2, - "gradientInfoStop3" to gradientInfoStop3, - "gradientInfoStop4" to gradientInfoStop4, - "gradientInfoStop5" to gradientInfoStop5, - "gradientInfoStop6" to gradientInfoStop6, "gradientSubtleStop1" to gradientSubtleStop1, "gradientSubtleStop2" to gradientSubtleStop2, "gradientSubtleStop3" to gradientSubtleStop3, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt index 1faed13425..0ed2253647 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt @@ -38,8 +38,11 @@ fun gradientSubtleColors(): List = listOf( fun gradientInfoColors(): List = listOf( ElementTheme.colors.gradientInfoStop1, ElementTheme.colors.gradientInfoStop2, - ElementTheme.colors.gradientInfoStop3, - ElementTheme.colors.gradientInfoStop4, - ElementTheme.colors.gradientInfoStop5, - ElementTheme.colors.gradientInfoStop6, +) + +@Composable +@ReadOnlyComposable +fun gradientCriticalColors(): List = listOf( + ElementTheme.colors.gradientCriticalStop1, + ElementTheme.colors.gradientCriticalStop2, ) From 723cfc77ff36b9aeaa78ab206b4aad825e036415 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 12:56:55 +0100 Subject: [PATCH 10/20] Use gradient color in ComposerAlertMolecule. Fixes #6192 --- .../identity/IdentityChangeStateView.kt | 2 +- .../atomic/molecules/ComposerAlertMolecule.kt | 24 +++++++++++-------- .../VoiceMessageRecorderButtonIcon.kt | 1 - 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt index 92352732b0..21684206b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt @@ -115,7 +115,7 @@ private fun ViolationAlert( }, submitText = stringResource(submitTextId), onSubmitClick = onSubmitClick, - level = if (isCritical) ComposerAlertLevel.Critical else ComposerAlertLevel.Default, + level = if (isCritical) ComposerAlertLevel.Critical else ComposerAlertLevel.Info, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt index 72994fec44..d77df1e245 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.colors.gradientCriticalColors +import io.element.android.libraries.designsystem.colors.gradientInfoColors import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarType @@ -38,6 +40,9 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2392-6721 + */ @Composable fun ComposerAlertMolecule( avatar: AvatarData?, @@ -57,12 +62,6 @@ fun ComposerAlertMolecule( ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle } - val startColor = when (level) { - ComposerAlertLevel.Default -> ElementTheme.colors.bgInfoSubtle - ComposerAlertLevel.Info -> ElementTheme.colors.bgInfoSubtle - ComposerAlertLevel.Critical -> ElementTheme.colors.bgCriticalSubtle - } - val textColor = when (level) { ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary @@ -75,12 +74,17 @@ fun ComposerAlertMolecule( .height(1.dp) .background(lineColor) ) - val brush = Brush.verticalGradient( - listOf(startColor, ElementTheme.colors.bgCanvasDefault), - ) + val gradientColors = when (level) { + ComposerAlertLevel.Default -> listOf( + ElementTheme.colors.bgInfoSubtle, + ElementTheme.colors.bgInfoSubtle, + ) + ComposerAlertLevel.Info -> gradientInfoColors() + ComposerAlertLevel.Critical -> gradientCriticalColors() + } Box( modifier = Modifier - .background(brush) + .background(Brush.verticalGradient(gradientColors)) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) ) { Column( diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt index f11bb1d5b7..e5468dc1fd 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt @@ -23,7 +23,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton -import io.element.android.libraries.designsystem.utils.CommonDrawables @Composable internal fun VoiceMessageRecorderButtonIcon( From 08b91071d233ad367302a6cb0ee0aa71989b5ecf Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 2 Mar 2026 12:19:18 +0000 Subject: [PATCH 11/20] Update screenshots --- libraries/compound/screenshots/Compound Icons - Dark.png | 4 ++-- libraries/compound/screenshots/Compound Icons - Light.png | 4 ++-- libraries/compound/screenshots/Compound Icons - Rtl.png | 4 ++-- .../screenshots/Compound Semantic Colors - Dark HC.png | 4 ++-- .../compound/screenshots/Compound Semantic Colors - Dark.png | 4 ++-- .../screenshots/Compound Semantic Colors - Light HC.png | 4 ++-- .../compound/screenshots/Compound Semantic Colors - Light.png | 4 ++-- .../compound/screenshots/Compound Vector Icons - Dark.png | 4 ++-- .../compound/screenshots/Compound Vector Icons - Light.png | 4 ++-- ...tures.lockscreen.impl.unlock.keypad_PinKeypad_Day_0_en.png | 4 ++-- ...res.lockscreen.impl.unlock.keypad_PinKeypad_Night_0_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_0_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_1_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_2_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_3_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_4_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_5_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_6_en.png | 4 ++-- ...features.lockscreen.impl.unlock_PinUnlockView_Day_7_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_0_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_1_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_2_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_3_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_4_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_5_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_6_en.png | 4 ++-- ...atures.lockscreen.impl.unlock_PinUnlockView_Night_7_en.png | 4 ++-- ....impl.crypto.identity_IdentityChangeStateView_Day_1_en.png | 4 ++-- ...mpl.crypto.identity_IdentityChangeStateView_Night_1_en.png | 4 ++-- ...rypto.identity_MessagesViewWithIdentityChange_Day_1_en.png | 4 ++-- ...pto.identity_MessagesViewWithIdentityChange_Night_1_en.png | 4 ++-- ...impl.messagecomposer_MessageComposerViewVoice_Day_0_en.png | 4 ++-- ...pl.messagecomposer_MessageComposerViewVoice_Night_0_en.png | 4 ++-- ...ponents.virtual_TimelineItemRoomBeginningView_Day_0_en.png | 4 ++-- ...nents.virtual_TimelineItemRoomBeginningView_Night_0_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Day_10_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Day_8_en.png | 4 ++-- .../features.messages.impl_MessagesView_Night_10_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Night_8_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_0_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_1_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_2_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png | 4 ++-- .../libraries.designsystem.icons_IconsOther_Day_0_en.png | 4 ++-- .../libraries.designsystem.icons_IconsOther_Night_0_en.png | 4 ++-- ...raries.designsystem.theme.components_AllIcons_Icons_en.png | 4 ++-- ...ser.components_VoiceMessageRecorderButtonIcon_Day_0_en.png | 4 ++-- ...r.components_VoiceMessageRecorderButtonIcon_Night_0_en.png | 4 ++-- ...es.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en.png | 4 ++-- ....textcomposer_TextComposerVoiceNotEncrypted_Night_0_en.png | 4 ++-- .../libraries.textcomposer_TextComposerVoice_Day_0_en.png | 4 ++-- .../libraries.textcomposer_TextComposerVoice_Night_0_en.png | 4 ++-- 54 files changed, 108 insertions(+), 108 deletions(-) diff --git a/libraries/compound/screenshots/Compound Icons - Dark.png b/libraries/compound/screenshots/Compound Icons - Dark.png index c0d816a6b9..a381536ce4 100644 --- a/libraries/compound/screenshots/Compound Icons - Dark.png +++ b/libraries/compound/screenshots/Compound Icons - Dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa16f659aa3e7d05fa03a51d52faddc0c40c3ab52231687f8c6c8a4ba81ff6f0 -size 219813 +oid sha256:460ddd253f4029b29edde9d858237204acb55aca7e13e92bc691ea71ca34c53e +size 237462 diff --git a/libraries/compound/screenshots/Compound Icons - Light.png b/libraries/compound/screenshots/Compound Icons - Light.png index fd105971e0..c26dbc59cb 100644 --- a/libraries/compound/screenshots/Compound Icons - Light.png +++ b/libraries/compound/screenshots/Compound Icons - Light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72fb457dc50bf1a2261502fc1da15c01ab415344e9070354d38dc7b74234d790 -size 232095 +oid sha256:a0b76eee73be6a2ba58eb12883477ebce7daf039b6c60637e263a478a0bc68fe +size 250668 diff --git a/libraries/compound/screenshots/Compound Icons - Rtl.png b/libraries/compound/screenshots/Compound Icons - Rtl.png index 50c98caee5..0af5bf0631 100644 --- a/libraries/compound/screenshots/Compound Icons - Rtl.png +++ b/libraries/compound/screenshots/Compound Icons - Rtl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24cfe760717881ee71f36fae1fb201e74b2c32a2f9a5aef71ef21dab69ea5366 -size 233212 +oid sha256:d12abdfa2a2d7d1943e0e377279134b44de0ad5d8fb12b09e23c2083b728989f +size 251951 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png b/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png index b66b386ed8..2a9029e303 100644 --- a/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png +++ b/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c846cd10b83361c368bdbb31ed6220cc22693c3cbf52791fb369841af1e9ea48 -size 327701 +oid sha256:afb0295b04f302c25f40774562e7d5b2bb668c4cf1158b521ae9b50a35a58d2b +size 322068 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Dark.png b/libraries/compound/screenshots/Compound Semantic Colors - Dark.png index 35d684d39b..60761e34e0 100644 --- a/libraries/compound/screenshots/Compound Semantic Colors - Dark.png +++ b/libraries/compound/screenshots/Compound Semantic Colors - Dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05b35fedbd53dec2cc5c4c211a8db1a56055963de69425ddae2cab5aff7e3e75 -size 325750 +oid sha256:3b94a6d004999869b8650559a70a1427882408b242c9b47788e56320aaeef34c +size 320114 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png b/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png index 9061d2b894..d20e3628c7 100644 --- a/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png +++ b/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d98e64eda5d6333067ccc599e99636f618331397207bb7534595e2756edb75e -size 309312 +oid sha256:06cebaaf9e0e4f2b69231ab2b866652419e70df50b0abb68288e08f748ed9b76 +size 301985 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Light.png b/libraries/compound/screenshots/Compound Semantic Colors - Light.png index 83c00ab51c..213f90fbc7 100644 --- a/libraries/compound/screenshots/Compound Semantic Colors - Light.png +++ b/libraries/compound/screenshots/Compound Semantic Colors - Light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ccbf1234065b182939f001eb65eca0a62adae41a2d91ef0307d27b059407178 -size 309084 +oid sha256:baf841165dfd7c6315dc7bd82d1be8935976d0a9a70e83f4d70e23a2389dab95 +size 301760 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Dark.png b/libraries/compound/screenshots/Compound Vector Icons - Dark.png index 638e4a5cba..b50dc7b82a 100644 --- a/libraries/compound/screenshots/Compound Vector Icons - Dark.png +++ b/libraries/compound/screenshots/Compound Vector Icons - Dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a699170cabca6fb912d034a588b45961485afe6ef6d2c24f0ab79f10ae00c168 -size 85629 +oid sha256:40f0940bd8a5ddee96ea2aac01d9672478fc15044621bfb10f5f0b20d61f035d +size 93402 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Light.png b/libraries/compound/screenshots/Compound Vector Icons - Light.png index d60a3fdadb..5e32ca9c59 100644 --- a/libraries/compound/screenshots/Compound Vector Icons - Light.png +++ b/libraries/compound/screenshots/Compound Vector Icons - Light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd4b2a40fcf02d6db29cb0bc371d93236b4a0be6d4446bab86358692cddb53f5 -size 91692 +oid sha256:48d8c1bef4a59554649fab33aa716ca2e9fe24f29a6b7e0dae9c404afedd6695 +size 99735 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Day_0_en.png index 4be53a66fb..abef40e89b 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:650cbf541a2fb9cabb99c5d94105d9e48bda8cae66cc5537ae463c33fd66b055 -size 27870 +oid sha256:7b5d9b30a6c0a1e47b98123e7adddbc349d5e7d4b7d1072322ff1893a7385c48 +size 27885 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Night_0_en.png index 891f61d9fe..b66e75a96a 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock.keypad_PinKeypad_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:744fe9ec84370f46992b776c550a8f28293b59c4e2c54b1350fcfbebf467a7a9 -size 26856 +oid sha256:beb8c5b4b2999ad1d21e99b8f83783298088582bbd5becfad66df257765d64a9 +size 26857 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_0_en.png index a7270b3bba..dd05b7e17f 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:430d5286c4a59eea7020ba082610374d0d69790734dbca3da814d86f4b740012 -size 35941 +oid sha256:011c3caceeb03e9162c5e60dd71d53212971e13b7a6b58692feb901ac13b79e1 +size 35844 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_1_en.png index a905bc2b4a..680f363dca 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c18873cedc9b1390af00d6ae3e6af5308cd806eb6b6ce638c924779d27588e6 -size 36346 +oid sha256:aef835a050377afd05e6a517107586160a86fce4f32344fd92d00a08b4a2c660 +size 36250 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_2_en.png index ca50cbd72a..8e99b487cf 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:405c000168f3369b4c942676905e02a1babac4405edb6ae0c366f9a51f3e3d79 -size 37177 +oid sha256:f36922d058b19410a38456a9e29760f9b890c1fc0549b3aba2a0e76d3dc64c54 +size 37079 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_3_en.png index cd6ad128f2..655573b7ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90728bbeccfddabbe88aa48e645ecef44e0bbcd06ffaa33afd3a09d15ab87d9c -size 40200 +oid sha256:3a1fce6ec4b708fab1df642899f8b12a335dc50c386e2ab2b6168b501069422b +size 40137 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_4_en.png index 0d5bb3759b..0af4ff0fcf 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df0885f9389247dd506b75869a20dba6330ef7e67343dfdc1478a25089ff7248 -size 33641 +oid sha256:ec435ce434370cb5ca711458b955e8843872b01675234eab15cad4ab6e08dc38 +size 33542 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_5_en.png index 0d2164576c..5160773456 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78ed552b1112f9fe6f64d7e1911543302eef7e481997850c55ab677a4774ced1 -size 37500 +oid sha256:f17bdcb653ee9c811b70fa98a975b76e73ea59a896fde869875d7fc8c84f8450 +size 37438 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_6_en.png index 42c3009458..19dd373269 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d4dd70566a5d285f4ae448076eadc03973c501cefae04469bd1dac7b07261d2 -size 29789 +oid sha256:4b939d947263360619b40986550bad3715439cd9037285410c32c26cc4078251 +size 29725 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_7_en.png index 7e7725b69d..9dd19fc627 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cfb45a91a984afc31376db5fe7038650527505b937f3d8dd444e22478c8770a6 -size 29870 +oid sha256:98252e4f345d6b34e37a8b065cb570f85b5678f6d81b36ef96ca3c5df9433a23 +size 29804 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_0_en.png index 9aa7e42b10..9d1bd22ef5 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99b099d03e53332d971cf703817af3ad6b0e5721ceb36c6a6ba98fb368bdeb80 -size 34684 +oid sha256:e87b9820e45b16a13d2f9fb0a470b5f57abf0e45f48d16e5049c4abca04e94ea +size 34629 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_1_en.png index 92b57a0376..bf74b3022e 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02c8fae133d02db2caae3d5ab09c8e124beaf7cec0dfa207d1c6551edfc14ef2 -size 35055 +oid sha256:74e88064bd3817c79443b6ed2df9b68e9e455d7bef2d94590854ce0d937e2107 +size 34997 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_2_en.png index a6674d46b7..d5087096c0 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96fc33b031be71d79f20059351531149893063f3366d745de7a55f05dc06e435 -size 35782 +oid sha256:23d3ded6d54724df9d03d382e12b58fc23970c354de52dc90cb0b13b66c9c614 +size 35729 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_3_en.png index d67d6639fc..9ce741484a 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ef4bde885a848b4b0be1406b979dcf6c21c3b521d783461d6de698286bac605 -size 37888 +oid sha256:be40f01d7694d9d92977037f9d84d9f1a1762f9d10ac20db271227032ecc18ff +size 37834 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_4_en.png index 5c4ec96250..22553fcf66 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ceeddcd04359b8c794dcd3f416409c4104b003f4fe68259f55bb17c2af274a3a -size 32529 +oid sha256:94ed59c67dacb5066006189c0daf6d55704495ecffd145d69ea3ba968ffda246 +size 32467 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_5_en.png index 8664799c10..fa1bc40c16 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e335823e079be1ad9e432cac403cab511fa15d854d9515b15554f392c0cdde81 -size 35435 +oid sha256:f204a4e3d113c1f835c959246c5abdaf8dcf601757f17219c83d57d356bc3fdb +size 35381 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_6_en.png index dfd2591e23..a0886cef42 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d8079dc52cde1c2116d08dd47ac5082c62bb7fe7e906575833da8fa3b10b885 -size 28402 +oid sha256:f76eed2d734821db5e84dcf93e4b83fcb64350f8d41a18827f0ec4242779c5a1 +size 28348 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_7_en.png index 50dab35ef3..9de7f9b9b3 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.unlock_PinUnlockView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:874201b58d12f6bc76861d74018a8bbe84a14adf01adea96d72dda0c6af4ae63 -size 27920 +oid sha256:d98c1f00e81a318d5df078cb91f88b1a16fe01bf37b835d9c8b976045d4b3023 +size 27860 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png index 9dc48d6c40..5d09fb79bb 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de136cb7d23d33665936dbd5c7633a5092e12b2ee5fd796fab473f43c8dc8a64 -size 23233 +oid sha256:bdcf85f3e7fdd7b922adb37e72bbf79908b8976398b063cdf388dc63a65656f3 +size 23179 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png index ae411ffa6f..c6ebac9797 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54518d42c92f2cc88af4edc94ba34fa96ced83a35e884266bd9637a7e8de2aaa -size 26351 +oid sha256:59d4cdd92b696e6a604a00188ab2127ef9397ed6f2d3244e923e604bf4d15509 +size 26143 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png index 15225ee23b..1d643d2454 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b73acbe8a6d63b782f19714ac19a5fab3cbd8e85c87ec0cd7b7cb9f093f891d -size 63257 +oid sha256:322669f2d24c4870b3852e6f73628d892650f56dc437b2db8654d1c0ef2baa19 +size 63295 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png index 2daa3219e5..7e0f695cbd 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d457c27d169001c2a59992fce130ed5a25144e39b182422cf4d31baeed708e5 -size 66652 +oid sha256:8710fb1f12d2337240f47be164892b0337e07f77e0b06faea659181fb5234a69 +size 66374 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Day_0_en.png index 6626c5d774..24b105d49f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0038fefb48b24eaf8226a38b8276fc44e5a106fef6125f581acb08189740374a -size 8622 +oid sha256:35dcf4715c24a1e228cd4cc030105bd89678ce154c3c540d854ba9e4a5ea55d3 +size 8746 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Night_0_en.png index c9856b2693..c2ca44acea 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerViewVoice_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6aa86db8a75ec9bd2680cb81d2089a2eda0d7d25625d95ef60a0e15f0392936d -size 8220 +oid sha256:d55f952bb21069f51dc7bbce90c18b35ca6b0d710478850dc7452f909169eb16 +size 8331 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png index 5815548686..8c444c0d05 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abc1526f441c218d39e44ef3f146d4fee3bbb0628c4ece25fb2ae4ef7e4100b0 -size 48820 +oid sha256:0f30735f80910428772fb5f084eac1950a7163d8211693cf65dadd6058f09dfb +size 40659 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png index e01465633c..e6ad5d3e48 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20c52bb8f44c186d3104d457c5de9c0597d99f0a5be37a9d8680fb4be35d422f -size 53777 +oid sha256:034910a5771e06c07f559917c81b4f94d5eaa36f90394ed21561083bacf5d777 +size 39682 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png index 7484268e7e..85070a6bc2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:503983c278dcf6cb1cd427a309fb3e0a60fc3882a4cbf9491fd0617d72058d88 -size 65267 +oid sha256:601d6945ec4869b2113762279301043910c1eb9771f3dc85bb8995140d66b663 +size 65298 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png index b8d8a2cf4c..b1d6c2c62e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cb21bd5e7d348d1d07ca4b6a360f26a4735cc9760fdd7e3b4b1bcde32da6f08 -size 62655 +oid sha256:75db744101463a0116d6784a89be961b2d551741bc62ed892e1e896fcf0c673c +size 57593 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png index e2fc20773a..e9fa9bb565 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ea79d33090c606b1ce22de0302380ba188bb81230ea9ee7b04a0f0e80bb19a0 -size 67921 +oid sha256:cd92f78ab9aa2288b3630cc0c7de96a53faa35c260242d6de757298675151fe0 +size 67645 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png index 6e371532de..5b3290ec0a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86273e812cbc6245527c3d1f138111ece99375aec0d68728882b656b01687bff -size 64392 +oid sha256:425212f4ee67fd8cfc7c0153b8f50f8faebec42b373e936229305260de5d0949 +size 56801 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png index f24d03b794..468e353988 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:345ce61fcc3e735c2a5c93b2dcb3b62e3046a322ee40a67ef45a5c1593a2d51d -size 17445 +oid sha256:99bcf32a0d1bd3734a1eb7cfa821439927b19fe5120c021f9a90470bc66817a7 +size 12941 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png index 112f74afee..66384df40e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64c2775cdfba87b15bad8692e6be8e5bd1a31b07d67361f523883b5e3537c68d -size 19626 +oid sha256:8b0627f61a38b771bcdeb270db5b819232cb9bf7718ba0c122a172bc505ad730 +size 13995 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png index fac56be590..b1130a4672 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bc715d121f67bfa0896aa6cd20f42d5423d1f50fc823f9d44a03ca8dbd7fa1d -size 19593 +oid sha256:4a77f9c7dd17745f213374fe31849469c0d6245aa061daf0e280595185bf26d2 +size 13926 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png index 484c8543f3..9d754849c9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:664312de0cceccb5a7bad7e97909fa7177fa70d90edce5b92a55fc84645b84d6 -size 20889 +oid sha256:685812792702bde50766c32aaf769a9d6849b670e8aa196596d57e9f90a2f7b5 +size 13075 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png index 896a94bfc7..82afb8c58b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55d008331c4d83f4e1411d151f1c8e77c56537f2d7457827900930f4d4194b29 -size 23100 +oid sha256:4ceecbb92d0edab1d7cc6a14f07082fb90371245235dfcbf32cd36586644c88b +size 14546 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png index 3696e544ad..ff2009d4ce 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8eacce2e989c03602201144a41fd97ee7a7d747a85ecdb5eea294af760914894 -size 22877 +oid sha256:cd295161407d847f0a9c61e856c09e715350d7fc8aae174ad00821b0c66b27d3 +size 14192 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Day_0_en.png index 3fd2e10649..c08c04d02c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe4d10dd2a8fc9e6d4047f40e0e3cb5e97a534ef9857ba557ffdac394c709c04 -size 13653 +oid sha256:e332ec62aa00bd409f33ebdb67240e4e55b1bead2df524522b6a4091343ad0a7 +size 12863 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Night_0_en.png index 744ab19b3a..7592d5a045 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.icons_IconsOther_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9112c6b84e24c58f6f230f390b09b368497348e3a4f913409ff40409cddda6c5 -size 13002 +oid sha256:3f1baf335253cac6c58e0305b2848c1753ef05f07ce9235c9d40928f1191ac7a +size 12223 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_AllIcons_Icons_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_AllIcons_Icons_en.png index 27ebcf92ac..d137fa3678 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_AllIcons_Icons_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_AllIcons_Icons_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:813b2ee132d7de92e384b185dea13b366a6760095e03c52a9f127bc21402c383 -size 105960 +oid sha256:9af7cbe72cb2e9905ed8b4eb7f9007eaf92bc8dec5b23e0af46d7b41f770b2a8 +size 113751 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Day_0_en.png index 035e81f958..79a641b898 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a1d961ff9854bc804ae3df561ae33f562523fa2ff9ca7ce3758c31c1d8a5c4e -size 5772 +oid sha256:ebfb2a9e5cb38253191c83d232b89db3f2e1fa5eb299aa34747e46eaa1b390fc +size 5898 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Night_0_en.png index d1a43082d9..93aa648ff4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bc1dee4cf07940aa2bdefb8a5927a6d93ab246f94e19bb625c25581717be3bf -size 5739 +oid sha256:e36f5044c7d30751eda3d5745e14821a0e6a51f4a6ef2b6e63e05475e1110bf9 +size 5828 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en.png index df095badd0..9b8ade34d4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71cf49cfdeb78764419825b26e75100c6dbc07e3ae20fcd4882b4dc63c56dcb7 -size 35961 +oid sha256:c394e9a8d80c6081b9759817e06077508d02d34b972edc6a44d5ebe428679448 +size 36087 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Night_0_en.png index 5d4b8e050b..6b976713b9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoiceNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa12f44951d01cd2fbcffe3ccf83e7205ea3cb83f681249599b40a36c5df5ad2 -size 34142 +oid sha256:e72a58d07138e2e771ff848f435cfb04956c4f96c66e07100dc1a1905c126fef +size 34267 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Day_0_en.png index e28e0dfe58..19c9f9f4b7 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d908cc7fe22e3f970aa597768d1a9acff8f89efc91b66f066ba1069dd4b22a78 -size 25108 +oid sha256:6852bf756c3f1d44e21409b884c422773c887b1623b2a80c8394394916ed87f6 +size 25210 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Night_0_en.png index c12da311f5..983960cb05 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerVoice_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1005f62c18a05ca7f49311412ba5986e5cc950fc7225e012eee6242c547afcdd -size 24066 +oid sha256:1376d130c9cc5ebf37786708ef3acd75ad11516db6d23a81906ff5ebbfa002d0 +size 24184 From 217c3a2b084ce5e0472254db3da463c3e1c92d6c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 2 Mar 2026 13:56:08 +0100 Subject: [PATCH 12/20] Remove ComposerAlertLevel.Default (not in the design). --- .../atomic/molecules/ComposerAlertMolecule.kt | 13 ++----------- .../ComposerAlertMoleculeParamsProvider.kt | 1 - 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt index d77df1e245..8b0f17177b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt @@ -49,7 +49,7 @@ fun ComposerAlertMolecule( content: AnnotatedString, onSubmitClick: () -> Unit, modifier: Modifier = Modifier, - level: ComposerAlertLevel = ComposerAlertLevel.Default, + level: ComposerAlertLevel = ComposerAlertLevel.Info, showIcon: Boolean = false, submitText: String = stringResource(CommonStrings.action_ok), ) { @@ -57,14 +57,12 @@ fun ComposerAlertMolecule( modifier.fillMaxWidth() ) { val lineColor = when (level) { - ComposerAlertLevel.Default -> ElementTheme.colors.borderInfoSubtle ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle } val textColor = when (level) { - ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary - ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary + ComposerAlertLevel.Info -> ElementTheme.colors.textPrimary ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary } @@ -75,10 +73,6 @@ fun ComposerAlertMolecule( .background(lineColor) ) val gradientColors = when (level) { - ComposerAlertLevel.Default -> listOf( - ElementTheme.colors.bgInfoSubtle, - ElementTheme.colors.bgInfoSubtle, - ) ComposerAlertLevel.Info -> gradientInfoColors() ComposerAlertLevel.Critical -> gradientCriticalColors() } @@ -100,12 +94,10 @@ fun ComposerAlertMolecule( ) } else if (showIcon) { val icon = when (level) { - ComposerAlertLevel.Default -> CompoundIcons.Info() ComposerAlertLevel.Info -> CompoundIcons.Info() ComposerAlertLevel.Critical -> CompoundIcons.Error() } val iconTint = when (level) { - ComposerAlertLevel.Default -> ElementTheme.colors.iconPrimary ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary } @@ -135,7 +127,6 @@ fun ComposerAlertMolecule( } enum class ComposerAlertLevel { - Default, Info, Critical } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt index 09027e0c91..cd1bc8a02b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt @@ -21,7 +21,6 @@ internal data class ComposerAlertMoleculeParams( internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider { private val allLevels = sequenceOf( - ComposerAlertLevel.Default, ComposerAlertLevel.Info, ComposerAlertLevel.Critical ) From d577bfd9dc45e6156ebf56e0c7862ee5fce372b9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 2 Mar 2026 13:15:54 +0000 Subject: [PATCH 13/20] Update screenshots --- ....impl.crypto.identity_IdentityChangeStateView_Day_1_en.png | 4 ++-- ...mpl.crypto.identity_IdentityChangeStateView_Night_1_en.png | 4 ++-- ...rypto.identity_MessagesViewWithIdentityChange_Day_1_en.png | 4 ++-- ...pto.identity_MessagesViewWithIdentityChange_Night_1_en.png | 4 ++-- ...ponents.virtual_TimelineItemRoomBeginningView_Day_0_en.png | 4 ++-- ...nents.virtual_TimelineItemRoomBeginningView_Night_0_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Day_10_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Day_8_en.png | 4 ++-- .../features.messages.impl_MessagesView_Night_10_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Night_8_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_0_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_1_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_2_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_3_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_4_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_5_en.png | 4 ++-- ...system.atomic.molecules_ComposerAlertMolecule_Day_6_en.png | 3 --- ...system.atomic.molecules_ComposerAlertMolecule_Day_7_en.png | 3 --- ...system.atomic.molecules_ComposerAlertMolecule_Day_8_en.png | 3 --- ...stem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_3_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_4_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_5_en.png | 4 ++-- ...stem.atomic.molecules_ComposerAlertMolecule_Night_6_en.png | 3 --- ...stem.atomic.molecules_ComposerAlertMolecule_Night_7_en.png | 3 --- ...stem.atomic.molecules_ComposerAlertMolecule_Night_8_en.png | 3 --- 28 files changed, 44 insertions(+), 62 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_7_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_8_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_7_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png index 5d09fb79bb..9dc48d6c40 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdcf85f3e7fdd7b922adb37e72bbf79908b8976398b063cdf388dc63a65656f3 -size 23179 +oid sha256:de136cb7d23d33665936dbd5c7633a5092e12b2ee5fd796fab473f43c8dc8a64 +size 23233 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png index c6ebac9797..ae411ffa6f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59d4cdd92b696e6a604a00188ab2127ef9397ed6f2d3244e923e604bf4d15509 -size 26143 +oid sha256:54518d42c92f2cc88af4edc94ba34fa96ced83a35e884266bd9637a7e8de2aaa +size 26351 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png index 1d643d2454..15225ee23b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:322669f2d24c4870b3852e6f73628d892650f56dc437b2db8654d1c0ef2baa19 -size 63295 +oid sha256:4b73acbe8a6d63b782f19714ac19a5fab3cbd8e85c87ec0cd7b7cb9f093f891d +size 63257 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png index 7e0f695cbd..2daa3219e5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8710fb1f12d2337240f47be164892b0337e07f77e0b06faea659181fb5234a69 -size 66374 +oid sha256:7d457c27d169001c2a59992fce130ed5a25144e39b182422cf4d31baeed708e5 +size 66652 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png index 8c444c0d05..5815548686 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f30735f80910428772fb5f084eac1950a7163d8211693cf65dadd6058f09dfb -size 40659 +oid sha256:abc1526f441c218d39e44ef3f146d4fee3bbb0628c4ece25fb2ae4ef7e4100b0 +size 48820 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png index e6ad5d3e48..e01465633c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:034910a5771e06c07f559917c81b4f94d5eaa36f90394ed21561083bacf5d777 -size 39682 +oid sha256:20c52bb8f44c186d3104d457c5de9c0597d99f0a5be37a9d8680fb4be35d422f +size 53777 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png index 85070a6bc2..7484268e7e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:601d6945ec4869b2113762279301043910c1eb9771f3dc85bb8995140d66b663 -size 65298 +oid sha256:503983c278dcf6cb1cd427a309fb3e0a60fc3882a4cbf9491fd0617d72058d88 +size 65267 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png index b1d6c2c62e..b8d8a2cf4c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75db744101463a0116d6784a89be961b2d551741bc62ed892e1e896fcf0c673c -size 57593 +oid sha256:1cb21bd5e7d348d1d07ca4b6a360f26a4735cc9760fdd7e3b4b1bcde32da6f08 +size 62655 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png index e9fa9bb565..e2fc20773a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd92f78ab9aa2288b3630cc0c7de96a53faa35c260242d6de757298675151fe0 -size 67645 +oid sha256:6ea79d33090c606b1ce22de0302380ba188bb81230ea9ee7b04a0f0e80bb19a0 +size 67921 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png index 5b3290ec0a..6e371532de 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:425212f4ee67fd8cfc7c0153b8f50f8faebec42b373e936229305260de5d0949 -size 56801 +oid sha256:86273e812cbc6245527c3d1f138111ece99375aec0d68728882b656b01687bff +size 64392 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png index 468e353988..f24d03b794 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99bcf32a0d1bd3734a1eb7cfa821439927b19fe5120c021f9a90470bc66817a7 -size 12941 +oid sha256:345ce61fcc3e735c2a5c93b2dcb3b62e3046a322ee40a67ef45a5c1593a2d51d +size 17445 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png index 66384df40e..112f74afee 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b0627f61a38b771bcdeb270db5b819232cb9bf7718ba0c122a172bc505ad730 -size 13995 +oid sha256:64c2775cdfba87b15bad8692e6be8e5bd1a31b07d67361f523883b5e3537c68d +size 19626 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png index b1130a4672..cfd3205f40 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a77f9c7dd17745f213374fe31849469c0d6245aa061daf0e280595185bf26d2 -size 13926 +oid sha256:dda7f51afe674c8c7cb0a38f4f042025b088de7518e7f40c51b114939f6448a1 +size 19871 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_en.png index c56aa067e6..a6c2015e4f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7639f45d1ee6e2f83508239166a6913cfd860bc65647de640ed3a42738422960 -size 17252 +oid sha256:ce0b5e05128196d4f14fd2b4e1c5b25f1eadec5f53011f310273962e24d30cc1 +size 16920 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_en.png index f1df8fdde2..da69c263fa 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff8a80e502af236bda4b6553293a6ff0ec3387b2945ee23d415513c1b3e1cac4 -size 19392 +oid sha256:7f77e13209da1562e1dd23c48d15315e7c74e8ad7e72b421309ba3bbc13c9ce4 +size 19849 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_en.png index 6c7ddc1769..0fc5a5766c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7745c2b9d122f2cce60503f6f2e745dcb91ae7fec37530b00d6e59cf56c5b9c -size 19289 +oid sha256:2a9f45cc63d97f5de0848cec943d3492242962b169c0ce50fb47e3b0d0cd5bea +size 19676 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_6_en.png deleted file mode 100644 index a6c2015e4f..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ce0b5e05128196d4f14fd2b4e1c5b25f1eadec5f53011f310273962e24d30cc1 -size 16920 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_7_en.png deleted file mode 100644 index da69c263fa..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7f77e13209da1562e1dd23c48d15315e7c74e8ad7e72b421309ba3bbc13c9ce4 -size 19849 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_8_en.png deleted file mode 100644 index 0fc5a5766c..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_8_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2a9f45cc63d97f5de0848cec943d3492242962b169c0ce50fb47e3b0d0cd5bea -size 19676 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png index 9d754849c9..484c8543f3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:685812792702bde50766c32aaf769a9d6849b670e8aa196596d57e9f90a2f7b5 -size 13075 +oid sha256:664312de0cceccb5a7bad7e97909fa7177fa70d90edce5b92a55fc84645b84d6 +size 20889 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png index 82afb8c58b..896a94bfc7 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ceecbb92d0edab1d7cc6a14f07082fb90371245235dfcbf32cd36586644c88b -size 14546 +oid sha256:55d008331c4d83f4e1411d151f1c8e77c56537f2d7457827900930f4d4194b29 +size 23100 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png index ff2009d4ce..f369fa9f5f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd295161407d847f0a9c61e856c09e715350d7fc8aae174ad00821b0c66b27d3 -size 14192 +oid sha256:0dbe9ea5e32714993a78db1f5a0e33f4276048f29021979f931607c9da55548a +size 22988 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_3_en.png index 20a7afdab8..181080e492 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44fd329498dc7b8c3258a25b4ef819b4f05363b9329be63dd76dcf0e59962845 -size 20406 +oid sha256:3358ff34d01f2261e98bbe595b0a3baf3377a35473e4bd90efae0b0cf5612028 +size 19388 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_4_en.png index e7faed0a60..ba58c597b7 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f355e2d2b80890c876e7a8f3a575c207788d875f49a3e0ccf9875190505fd387 -size 22798 +oid sha256:2788dc05b5d242670ffab703ef220990d5c48538d3f4bc3654b81f6b5e72c7ea +size 22415 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_5_en.png index 3dfaba3ecf..d8a9d0e0ef 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9942fbfa7c5fb11250683339269c0409ff67e228c4a7cbf9c74efe16e7bcef53 -size 22506 +oid sha256:1a96b3725a2b81c928f95e368c327916609fbad2aba74d07e6d7b5fbaee42ba4 +size 21954 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_6_en.png deleted file mode 100644 index 181080e492..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3358ff34d01f2261e98bbe595b0a3baf3377a35473e4bd90efae0b0cf5612028 -size 19388 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_7_en.png deleted file mode 100644 index ba58c597b7..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2788dc05b5d242670ffab703ef220990d5c48538d3f4bc3654b81f6b5e72c7ea -size 22415 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_8_en.png deleted file mode 100644 index d8a9d0e0ef..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_8_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1a96b3725a2b81c928f95e368c327916609fbad2aba74d07e6d7b5fbaee42ba4 -size 21954 From 70d5e1868a13672fb3c1a5dd18be28e79376daca Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 3 Mar 2026 13:16:58 +0100 Subject: [PATCH 14/20] Make 'room list catch-up' analytics transaction network aware (#6233) * Make 'room list catch-up' analytics transaction network aware. * Add `RoomListService.isInitialSyncDone`. Use this to simplify `DefaultAnalyticsRoomListStateWatcher`'s logic. --- .../matrix/api/roomlist/RoomListService.kt | 5 ++ .../impl/roomlist/RustRoomListService.kt | 7 +++ .../test/roomlist/FakeRoomListService.kt | 4 ++ .../analytics/api/NoopAnalyticsTransaction.kt | 3 ++ services/analytics/impl/build.gradle.kts | 2 + .../DefaultAnalyticsRoomListStateWatcher.kt | 52 ++++++++++++------- ...efaultAnalyticsRoomListStateWatcherTest.kt | 41 ++++++++------- .../api/AnalyticsTransaction.kt | 7 +++ .../sentry/SentryAnalyticsTransaction.kt | 8 +++ 9 files changed, 91 insertions(+), 38 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt index 8acfcccdd8..627b5500cf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -35,6 +35,11 @@ interface RoomListService { data object Hide : SyncIndicator } + /** + * Indicates whether the initial sliding sync request is done or not. + */ + val isInitialSyncDone: Boolean + /** * Creates a room list that can be used to load more rooms and filter them dynamically. * @param pageSize the number of rooms to load at once. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt index 70a2ac9f93..f5e3c32371 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.stateIn import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService internal class RustRoomListService( @@ -33,6 +34,9 @@ internal class RustRoomListService( private val roomSyncSubscriber: RoomSyncSubscriber, private val sessionCoroutineScope: CoroutineScope, ) : RoomListService { + private val _isInitialSyncDone = AtomicBoolean(false) + override val isInitialSyncDone: Boolean get() = _isInitialSyncDone.get() + override fun createRoomList( pageSize: Int, source: RoomList.Source, @@ -75,6 +79,9 @@ internal class RustRoomListService( .map { it.toRoomListState() } .onEach { state -> Timber.d("RoomList state=$state") + if (state == RoomListService.State.Running) { + _isInitialSyncDone.set(true) + } } .distinctUntilChanged() .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt index cdac4cd16a..76245250af 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt @@ -20,10 +20,14 @@ class FakeRoomListService( private val subscribeToVisibleRoomsLambda: (List) -> Unit = {}, private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) }, override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE), + private val isInitialSyncLambda: () -> Boolean = { true }, ) : RoomListService { private val roomListStateFlow = MutableStateFlow(RoomListService.State.Idle) private val syncIndicatorStateFlow = MutableStateFlow(RoomListService.SyncIndicator.Hide) + override val isInitialSyncDone: Boolean + get() = isInitialSyncLambda() + suspend fun postState(state: RoomListService.State) { roomListStateFlow.emit(state) } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt index 914fac1b12..205ce36ad0 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt @@ -8,8 +8,11 @@ package io.element.android.services.analytics.api import io.element.android.services.analyticsproviders.api.AnalyticsTransaction +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds object NoopAnalyticsTransaction : AnalyticsTransaction { + override val duration: Duration = 0.seconds override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction override fun putExtraData(key: String, value: String) {} override fun putIndexableData(key: String, value: String) {} diff --git a/services/analytics/impl/build.gradle.kts b/services/analytics/impl/build.gradle.kts index 15465b1727..cbb714033c 100644 --- a/services/analytics/impl/build.gradle.kts +++ b/services/analytics/impl/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) + implementation(projects.features.networkmonitor.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.services.appnavstate.api) @@ -37,6 +38,7 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.features.networkmonitor.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.analyticsproviders.test) testImplementation(projects.services.appnavstate.test) diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt index 75dffd0af1..bffc99911c 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt @@ -8,29 +8,32 @@ package io.element.android.services.analytics.impl.watchers import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope -import io.element.android.libraries.core.coroutine.withPreviousValue import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher -import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.minutes @ContributesBinding(SessionScope::class) class DefaultAnalyticsRoomListStateWatcher( - private val appNavigationStateService: AppNavigationStateService, + private val appForegroundStateService: AppForegroundStateService, + private val networkMonitor: NetworkMonitor, private val roomListService: RoomListService, private val analyticsService: AnalyticsService, @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, @@ -38,7 +41,7 @@ class DefaultAnalyticsRoomListStateWatcher( ) : AnalyticsRoomListStateWatcher { private val coroutineScope: CoroutineScope = sessionCoroutineScope.childScope(dispatchers.computation, "AnalyticsRoomListStateWatcher") private val isStarted = AtomicBoolean(false) - private val isWarmState = AtomicBoolean(false) + private val isNotInitialSync get() = roomListService.isInitialSyncDone override fun start() { if (isStarted.getAndSet(true)) { @@ -48,27 +51,40 @@ class DefaultAnalyticsRoomListStateWatcher( val longRunningTransaction = AnalyticsLongRunningTransaction.CatchUp - appNavigationStateService.appNavigationState - .map { it.isInForeground } + val hasNetworkConnectivityFlow = networkMonitor.connectivity + .map { it == NetworkStatus.Connected } .distinctUntilChanged() - .withPreviousValue() - .onEach { (wasInForeground, isInForeground) -> - if (isInForeground && roomListService.state.value != RoomListService.State.Running) { - analyticsService.startLongRunningTransaction(longRunningTransaction) - } else if (!isInForeground) { - analyticsService.removeLongRunningTransaction(longRunningTransaction) - } - if (wasInForeground == false && isInForeground) { - isWarmState.set(true) + combine( + appForegroundStateService.isInForeground, + hasNetworkConnectivityFlow, + ) { isInForeground, hasNetworkConnectivity -> + val canSync = isInForeground && hasNetworkConnectivity + val isNotSyncing = roomListService.state.value != RoomListService.State.Running + if (isNotInitialSync && canSync && isNotSyncing) { + Timber.d("Catch-up transaction: starting") + analyticsService.startLongRunningTransaction(longRunningTransaction) + } else if (!isInForeground || !hasNetworkConnectivity) { + analyticsService.removeLongRunningTransaction(longRunningTransaction)?.let { + Timber.d("Catch-up transaction: stopping") + } } } .launchIn(coroutineScope) roomListService.state .onEach { state -> - if (state == RoomListService.State.Running && isWarmState.get()) { - analyticsService.finishLongRunningTransaction(longRunningTransaction) + if (state == RoomListService.State.Running && isNotInitialSync) { + val transaction = analyticsService.removeLongRunningTransaction(longRunningTransaction) + if (transaction != null && !transaction.isFinished()) { + val duration = transaction.duration + if (duration > 3.minutes) { + Timber.d("Cancelling catch-up transaction, the elapsed time is too long ($duration), something probably went wrong while measuring") + } else { + Timber.d("Catch-up transaction finished in $duration") + transaction.finish() + } + } } } .launchIn(coroutineScope) diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 5e378f6a31..494fa45bde 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -8,13 +8,12 @@ package io.element.android.services.analytics.impl.watchers import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.CatchUp import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.services.appnavstate.api.AppNavigationState -import io.element.android.services.appnavstate.api.NavigationState -import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -26,13 +25,13 @@ import org.junit.Test class DefaultAnalyticsRoomListStateWatcherTest { @Test fun `Opening the app in a warm state tracks the time until the room list is synced`() = runTest { - val navigationStateService = FakeAppNavigationStateService() + val appForegroundStateService = FakeAppForegroundStateService() val roomListService = FakeRoomListService().apply { postState(RoomListService.State.Idle) } val analyticsService = FakeAnalyticsService() val watcher = createAnalyticsRoomListStateWatcher( - appNavigationStateService = navigationStateService, + appForegroundStateService = appForegroundStateService, roomListService = roomListService, analyticsService = analyticsService, ) @@ -43,9 +42,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + appForegroundStateService.givenIsInForeground(false) runCurrent() - navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + appForegroundStateService.givenIsInForeground(true) runCurrent() // The transaction should be present now @@ -63,15 +62,15 @@ class DefaultAnalyticsRoomListStateWatcherTest { @Test fun `Opening the app in a cold state does nothing`() = runTest { - val navigationStateService = FakeAppNavigationStateService( - initialAppNavigationState = AppNavigationState(NavigationState.Root, false) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false ) val roomListService = FakeRoomListService().apply { postState(RoomListService.State.Idle) } val analyticsService = FakeAnalyticsService() val watcher = createAnalyticsRoomListStateWatcher( - appNavigationStateService = navigationStateService, + appForegroundStateService = appForegroundStateService, roomListService = roomListService, analyticsService = analyticsService, ) @@ -93,13 +92,13 @@ class DefaultAnalyticsRoomListStateWatcherTest { @Test fun `The transaction won't be finished until the room list is synchronised`() = runTest { - val navigationStateService = FakeAppNavigationStateService() + val appForegroundStateService = FakeAppForegroundStateService() val roomListService = FakeRoomListService().apply { postState(RoomListService.State.Idle) } val analyticsService = FakeAnalyticsService() val watcher = createAnalyticsRoomListStateWatcher( - appNavigationStateService = navigationStateService, + appForegroundStateService = appForegroundStateService, roomListService = roomListService, analyticsService = analyticsService, ) @@ -110,9 +109,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + appForegroundStateService.givenIsInForeground(false) runCurrent() - navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + appForegroundStateService.givenIsInForeground(true) runCurrent() // The transaction should be present now @@ -128,13 +127,13 @@ class DefaultAnalyticsRoomListStateWatcherTest { @Test fun `Opening the app when the room list state was already Running does nothing`() = runTest { - val navigationStateService = FakeAppNavigationStateService() + val appForegroundStateService = FakeAppForegroundStateService() val roomListService = FakeRoomListService().apply { postState(RoomListService.State.Running) } val analyticsService = FakeAnalyticsService() val watcher = createAnalyticsRoomListStateWatcher( - appNavigationStateService = navigationStateService, + appForegroundStateService = appForegroundStateService, roomListService = roomListService, analyticsService = analyticsService, ) @@ -145,9 +144,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + appForegroundStateService.givenIsInForeground(false) runCurrent() - navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + appForegroundStateService.givenIsInForeground(true) runCurrent() // The transaction was never added @@ -157,14 +156,16 @@ class DefaultAnalyticsRoomListStateWatcherTest { } private fun TestScope.createAnalyticsRoomListStateWatcher( - appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService(), + appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), roomListService: FakeRoomListService = FakeRoomListService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), ) = DefaultAnalyticsRoomListStateWatcher( - appNavigationStateService = appNavigationStateService, + appForegroundStateService = appForegroundStateService, roomListService = roomListService, analyticsService = analyticsService, sessionCoroutineScope = backgroundScope, dispatchers = testCoroutineDispatchers(), + networkMonitor = networkMonitor, ) } diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt index b5f81ac67e..82d337914a 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt @@ -7,7 +7,14 @@ package io.element.android.services.analyticsproviders.api +import kotlin.time.Duration + interface AnalyticsTransaction { + /** + * The time elapsed since the transaction started until now if the transaction is ongoing or the time it finished. + */ + val duration: Duration + /** * Start a child span from this transaction. */ diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt index 6477b2cc5c..a167176284 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt @@ -11,7 +11,10 @@ import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.sentry.ISpan import io.sentry.ITransaction import io.sentry.Sentry +import io.sentry.SentryInstantDate import timber.log.Timber +import kotlin.time.Duration +import kotlin.time.Duration.Companion.nanoseconds class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction { constructor(name: String, operation: String?, description: String? = null) : this( @@ -19,6 +22,11 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra ) private val inner = span + @Suppress("UnstableApiUsage") + override val duration: Duration get() { + return (inner.finishDate ?: SentryInstantDate()).diff(inner.startDate).nanoseconds + } + override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction( inner.startChild(operation, description) ) From 7c97ec115525b3cc7708b2c22d3362868a55efaf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:39:43 +0100 Subject: [PATCH 15/20] Update metro to v0.11.2 (#6270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update metro to v0.11.2 * Bind push tests to the right scope .Add a comment so we don't forget to do it for future ones. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jorge Martín --- gradle/libs.versions.toml | 2 +- .../NotificationTroubleshootCheckPermissionTest.kt | 4 ++-- .../libraries/push/impl/troubleshoot/PushProvidersTest.kt | 4 ++-- .../firebase/troubleshoot/FirebaseAvailabilityTest.kt | 4 ++-- .../firebase/troubleshoot/FirebaseTokenTest.kt | 4 ++-- .../unifiedpush/troubleshoot/UnifiedPushTest.kt | 4 ++-- .../troubleshoot/api/test/NotificationTroubleshootTest.kt | 8 ++++++++ 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f5f7f4bcb..bb2f62ee07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ haze = "1.7.2" dependencyAnalysis = "3.6.0" # DI -metro = "0.11.1" +metro = "0.11.2" # Auto service autoservice = "1.1.1" diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt index 6f29415968..162ff416c5 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt @@ -10,8 +10,8 @@ package io.element.android.libraries.permissions.impl.troubleshoot import android.Manifest import android.os.Build -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.impl.R import io.element.android.libraries.permissions.impl.action.PermissionActions @@ -24,7 +24,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -@ContributesIntoSet(AppScope::class) +@ContributesIntoSet(SessionScope::class) class NotificationTroubleshootCheckPermissionTest( private val permissionStateProvider: PermissionStateProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt index dbd2c4a16b..1f69c97a95 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt @@ -8,8 +8,8 @@ package io.element.android.libraries.push.impl.troubleshoot -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.push.impl.R import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest @@ -19,7 +19,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -@ContributesIntoSet(AppScope::class) +@ContributesIntoSet(SessionScope::class) class PushProvidersTest( pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val stringProvider: StringProvider, diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt index 3f4eeca305..fde8bbf025 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt @@ -8,8 +8,8 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable import io.element.android.libraries.pushproviders.firebase.R @@ -21,7 +21,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -@ContributesIntoSet(AppScope::class) +@ContributesIntoSet(SessionScope::class) class FirebaseAvailabilityTest( private val isPlayServiceAvailable: IsPlayServiceAvailable, private val stringProvider: StringProvider, diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt index 22fa999d08..7ea7c6cee2 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt @@ -8,8 +8,8 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.pushproviders.firebase.FirebaseStore import io.element.android.libraries.pushproviders.firebase.FirebaseTroubleshooter @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -@ContributesIntoSet(AppScope::class) +@ContributesIntoSet(SessionScope::class) class FirebaseTokenTest( private val firebaseStore: FirebaseStore, private val firebaseTroubleshooter: FirebaseTroubleshooter, diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt index 328b5d8ac2..5bbea6a361 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt @@ -8,8 +8,8 @@ package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.pushproviders.unifiedpush.R import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushDistributorProvider @@ -22,7 +22,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -@ContributesIntoSet(AppScope::class) +@ContributesIntoSet(SessionScope::class) class UnifiedPushTest( private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider, private val openDistributorWebPageAction: OpenDistributorWebPageAction, diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt index 23144644d1..27f3bcf8ed 100644 --- a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt @@ -8,9 +8,17 @@ package io.element.android.libraries.troubleshoot.api.test +import io.element.android.libraries.di.SessionScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow +/** + * A test to troubleshoot notifications issues. + * Each test has a state that can be observed to update the UI accordingly. + * + * **IMPORTANT**: classes implementing this should be scoped to [SessionScope], otherwise Metro complains about these not being used: + * the component they're injected into is bound to [SessionScope] and so should these (https://github.com/ZacSweers/metro/issues/1932). + */ interface NotificationTroubleshootTest { val order: Int val state: StateFlow From 9c8757e38b76b9d2ef6e4daae9096af6df929f29 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 3 Mar 2026 14:12:33 +0100 Subject: [PATCH 16/20] Use `ShareIntentHandler` early to avoid distributing the whole intent (#6274) * Use `ShareIntentHandler` early to avoid distributing the whole intent This would make the intent be serialized as part of `NavTarget` and could potentially lead to `TransactionTooLargeException`s. We now pass a new `ShareIntentData` class around, containing the minimum amount of data needed. We also have a new `OnSharedData` post-processor to revoke uri access after they've been shared. * Move `UriToShare` next to `ShareIntentData` and add docs --- appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInFlowNode.kt | 10 +-- .../io/element/android/appnav/RootFlowNode.kt | 17 ++--- .../android/appnav/intent/IntentResolver.kt | 8 ++- .../appnav/intent/IntentResolverTest.kt | 19 +++++- .../features/share/api/OnSharedData.kt | 15 ++++ .../features/share/api/ShareEntryPoint.kt | 3 +- .../features/share/api/ShareIntentData.kt | 38 +++++++++++ .../features/share/api/ShareIntentHandler.kt | 21 ++++++ features/share/impl/build.gradle.kts | 1 + .../share/impl/DefaultOnSharedData.kt | 50 ++++++++++++++ .../share/impl/DefaultShareEntryPoint.kt | 2 +- ...andler.kt => DefaultShareIntentHandler.kt} | 68 +++++-------------- .../android/features/share/impl/ShareNode.kt | 6 +- .../features/share/impl/SharePresenter.kt | 48 +++++++------ .../share/impl/DefaultShareEntryPointTest.kt | 7 +- .../share/impl/FakeShareIntentHandler.kt | 27 -------- .../features/share/impl/SharePresenterTest.kt | 42 +++++++----- features/share/test/build.gradle.kts | 21 ++++++ .../share/test/FakeShareIntentHandler.kt | 22 ++++++ 20 files changed, 285 insertions(+), 141 deletions(-) create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt rename features/share/impl/src/main/kotlin/io/element/android/features/share/impl/{ShareIntentHandler.kt => DefaultShareIntentHandler.kt} (69%) delete mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt create mode 100644 features/share/test/build.gradle.kts create mode 100644 features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 8bc821f933..ecac391216 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.features.login.test) + testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.preferences.test) 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 2fe9e970c4..a676df1d32 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -8,7 +8,6 @@ package io.element.android.appnav -import android.content.Intent import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -63,6 +62,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint @@ -307,7 +307,7 @@ class LoggedInFlowNode( data object RoomDirectory : NavTarget @Parcelize - data class IncomingShare(val intent: Intent) : NavTarget + data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget @Parcelize data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget @@ -570,7 +570,7 @@ class LoggedInFlowNode( shareEntryPoint.createNode( parentNode = this, buildContext = buildContext, - params = ShareEntryPoint.Params(intent = navTarget.intent), + params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), callback = object : ShareEntryPoint.Callback { override fun onDone(roomIds: List) { // Remove the incoming share screen @@ -649,13 +649,13 @@ class LoggedInFlowNode( } } - internal suspend fun attachIncomingShare(intent: Intent) { + internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) { waitForNavTargetAttached { navTarget -> navTarget is NavTarget.Home } attachChild { backstack.push( - NavTarget.IncomingShare(intent) + NavTarget.IncomingShare(shareIntentData) ) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index f18fc138c1..745ab390b2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -44,6 +44,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.BackstackView @@ -265,7 +266,7 @@ class RootFlowNode( @Parcelize data class AccountSelect( val currentSessionId: SessionId, - val intent: Intent?, + val shareIntentData: ShareIntentData?, val permalinkData: PermalinkData?, ) : NavTarget @@ -357,8 +358,8 @@ class RootFlowNode( backstack.pop() } attachSession(sessionId).apply { - if (navTarget.intent != null) { - attachIncomingShare(navTarget.intent) + if (navTarget.shareIntentData != null) { + attachIncomingShare(navTarget.shareIntentData) } else if (navTarget.permalinkData != null) { attachPermalinkData(navTarget.permalinkData) } @@ -392,7 +393,7 @@ class RootFlowNode( is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) - is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) + is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.shareIntentData) } } @@ -423,7 +424,7 @@ class RootFlowNode( } } - private suspend fun onIncomingShare(intent: Intent) { + private suspend fun onIncomingShare(shareIntentData: ShareIntentData) { // Is there a session already? val latestSessionId = sessionStore.getLatestSessionId() if (latestSessionId == null) { @@ -437,13 +438,13 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - intent = intent, + shareIntentData = shareIntentData, permalinkData = null, ) ) } else { // Only one account, directly attach the incoming share node. - loggedInFlowNode.attachIncomingShare(intent) + loggedInFlowNode.attachIncomingShare(shareIntentData) } } } @@ -467,7 +468,7 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - intent = null, + shareIntentData = null, permalinkData = permalinkData, ) ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index 3e26130c78..6844db3ed6 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -12,6 +12,8 @@ import android.content.Intent import dev.zacsweers.metro.Inject import io.element.android.features.login.api.LoginIntentResolver import io.element.android.features.login.api.LoginParams +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.ShareIntentHandler import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -25,7 +27,7 @@ sealed interface ResolvedIntent { data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent data class Login(val params: LoginParams) : ResolvedIntent - data class IncomingShare(val intent: Intent) : ResolvedIntent + data class IncomingShare(val shareIntentData: ShareIntentData) : ResolvedIntent } @Inject @@ -34,6 +36,7 @@ class IntentResolver( private val loginIntentResolver: LoginIntentResolver, private val oidcIntentResolver: OidcIntentResolver, private val permalinkParser: PermalinkParser, + private val shareIntentHandler: ShareIntentHandler, ) { fun resolve(intent: Intent): ResolvedIntent? { if (intent.canBeIgnored()) return null @@ -62,7 +65,8 @@ class IntentResolver( if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { - return ResolvedIntent.IncomingShare(intent) + val data = shareIntentHandler.handleIncomingShareIntent(intent) ?: return null + return ResolvedIntent.IncomingShare(data) } // Unknown intent diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index bf673602c1..576e1aaea6 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -15,6 +15,9 @@ import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -239,26 +242,34 @@ class IntentResolverTest { @Test fun `test incoming share simple`() { + val shareIntentData = ShareIntentData.PlainText("Hello") val sut = createIntentResolver( oidcIntentResolverResult = { null }, + onIncomingShareIntent = { shareIntentData }, ) val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "Hello") } val result = sut.resolve(intent) - assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData)) } @Test fun `test incoming share multiple`() { + val fileUri = "content://com.example.app/file1.jpg".toUri() + val shareIntentData = ShareIntentData.Uris(text = "Hello", uris = listOf(UriToShare(fileUri, "image/jpg"))) val sut = createIntentResolver( oidcIntentResolverResult = { null }, + onIncomingShareIntent = { shareIntentData }, ) val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { action = Intent.ACTION_SEND_MULTIPLE + putExtra(Intent.EXTRA_TEXT, "Hello") + data = fileUri } val result = sut.resolve(intent) - assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData)) } @Test @@ -296,6 +307,7 @@ class IntentResolverTest { permalinkParserResult: (String) -> PermalinkData = { lambdaError() }, loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() }, oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() }, + onIncomingShareIntent: (Intent) -> ShareIntentData? = { null }, ): IntentResolver { return IntentResolver( deeplinkParser = { deeplinkParserResult }, @@ -308,6 +320,9 @@ class IntentResolverTest { permalinkParser = FakePermalinkParser( result = permalinkParserResult ), + shareIntentHandler = FakeShareIntentHandler( + onIncomingShareIntent = onIncomingShareIntent, + ), ) } } diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt new file mode 100644 index 0000000000..d7985e8b06 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +/** + * Post-processing to be done once a [ShareIntentData] has been consumed. + */ +fun interface OnSharedData { + operator fun invoke(data: ShareIntentData) +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt index 4d8eef9116..c83d4fbdc6 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -8,7 +8,6 @@ package io.element.android.features.share.api -import android.content.Intent import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -16,7 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId interface ShareEntryPoint : FeatureEntryPoint { - data class Params(val intent: Intent) + data class Params(val shareIntentData: ShareIntentData) fun createNode( parentNode: Node, diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt new file mode 100644 index 0000000000..e5407c57d9 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Share intent data, mapped from the original [android.content.Intent]. + */ +sealed interface ShareIntentData : Parcelable { + /** + * A list of [Uri]s to share and their mime types, with an optional [text] to be used as caption. + */ + @Parcelize + data class Uris(val text: String?, val uris: List) : ShareIntentData + + /** + * A plain text to share. + */ + @Parcelize + data class PlainText(val content: String) : ShareIntentData +} + +/** + * A [Uri] coming from an external share intent, with its associated [mimeType]. + */ +@Parcelize +data class UriToShare( + val uri: Uri, + val mimeType: String, +) : Parcelable diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt new file mode 100644 index 0000000000..d689c57398 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +import android.content.Intent + +interface ShareIntentHandler { + /** + * This methods aims to handle incoming share intents and parse its data. + * + * @return the [ShareIntentData] if it could be resolved, or null. + */ + fun handleIncomingShareIntent( + intent: Intent + ): ShareIntentData? +} diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 73748095a9..255263cf02 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { api(projects.features.share.api) testCommonDependencies(libs, true) + testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.preferences.test) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt new file mode 100644 index 0000000000..3a71f02dc3 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber +import kotlin.collections.forEach + +@ContributesBinding(AppScope::class) +class DefaultOnSharedData( + @ApplicationContext private val context: Context, +) : OnSharedData { + override fun invoke(data: ShareIntentData) { + when (data) { + is ShareIntentData.PlainText -> { + // No-op, there is nothing to do for plain text intents. + } + is ShareIntentData.Uris -> { + revokeUriPermissions(data.uris.map { it.uri }) + } + } + } + + private fun revokeUriPermissions(uris: List) { + uris.forEach { uri -> + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } catch (e: Exception) { + Timber.w(e, "Unable to revoke Uri permission") + } + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt index a8ae4d71c6..98d4acc472 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -26,7 +26,7 @@ class DefaultShareEntryPoint : ShareEntryPoint { return parentNode.createNode( buildContext = buildContext, plugins = listOf( - ShareNode.Inputs(intent = params.intent), + ShareNode.Inputs(shareIntentData = params.shareIntentData), callback, ) ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt similarity index 69% rename from features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt rename to features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt index 9342ef60d4..cfa06c3a97 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -14,10 +13,12 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri -import android.os.Build import androidx.core.content.IntentCompat import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.ShareIntentHandler +import io.element.android.features.share.api.UriToShare import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny @@ -30,37 +31,17 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.di.annotations.ApplicationContext import timber.log.Timber -interface ShareIntentHandler { - data class UriToShare( - val uri: Uri, - val mimeType: String, - ) - - /** - * This methods aims to handle incoming share intents. - * - * @return true if it can handle the intent data, false otherwise - */ - suspend fun handleIncomingShareIntent( - intent: Intent, - onUris: suspend (List) -> Boolean, - onPlainText: suspend (String) -> Boolean, - ): Boolean -} - @ContributesBinding(AppScope::class) class DefaultShareIntentHandler( @ApplicationContext private val context: Context, ) : ShareIntentHandler { - override suspend fun handleIncomingShareIntent( + override fun handleIncomingShareIntent( intent: Intent, - onUris: suspend (List) -> Boolean, - onPlainText: suspend (String) -> Boolean, - ): Boolean { - val type = intent.resolveType(context) ?: return false + ): ShareIntentData? { + val type = intent.resolveType(context) ?: return null val uris = getIncomingUris(intent, type) return when { - uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) + uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent) type.isMimeTypeImage() || type.isMimeTypeVideo() || type.isMimeTypeAudio() || @@ -68,20 +49,21 @@ class DefaultShareIntentHandler( type.isMimeTypeFile() || type.isMimeTypeText() || type.isMimeTypeAny() -> { - val result = onUris(uris) - revokeUriPermissions(uris.map { it.uri }) - result + ShareIntentData.Uris( + text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotEmpty() }, + uris = uris, + ) } - else -> false + else -> null } } - private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean { + private fun handlePlainText(intent: Intent): ShareIntentData.PlainText? { val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() return if (content?.isNotEmpty() == true) { - onPlainText(content) + ShareIntentData.PlainText(content) } else { - false + null } } @@ -89,7 +71,7 @@ class DefaultShareIntentHandler( * Use this function to retrieve files which are shared from another application or internally * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. */ - private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List { + private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List { val uriList = mutableListOf() if (intent.action == Intent.ACTION_SEND) { IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) @@ -118,24 +100,10 @@ class DefaultShareIntentHandler( // The value in fallbackMimeType can be wrong, especially if several uris were received // in the same intent (i.e. 'image/*'). We need to check the mime type of each uri. val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType - ShareIntentHandler.UriToShare( + UriToShare( uri = uri, mimeType = mimeType, ) } } - - private fun revokeUriPermissions(uris: List) { - uris.forEach { uri -> - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } else { - context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - } catch (e: Exception) { - Timber.w(e, "Unable to revoke Uri permission") - } - } - } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index b91c484ef3..0597b3b678 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -8,7 +8,6 @@ package io.element.android.features.share.impl -import android.content.Intent import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -23,6 +22,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs @@ -50,10 +50,10 @@ class ShareNode( @Parcelize object NavTarget : Parcelable - data class Inputs(val intent: Intent) : NodeInputs + data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.intent) + private val presenter = presenterFactory.create(inputs.shareIntentData) private val callback: ShareEntryPoint.Callback = callback() override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 4a4086ed87..93a9ae3bf4 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -8,13 +8,14 @@ package io.element.android.features.share.impl -import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState @@ -32,24 +33,24 @@ import kotlin.coroutines.cancellation.CancellationException @AssistedInject class SharePresenter( - @Assisted private val intent: Intent, + @Assisted private val shareIntentData: ShareIntentData, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, - private val shareIntentHandler: ShareIntentHandler, private val matrixClient: MatrixClient, private val mediaSenderRoomFactory: MediaSenderRoomFactory, private val activeRoomsHolder: ActiveRoomsHolder, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val onSharedData: OnSharedData, ) : Presenter { @AssistedFactory fun interface Factory { - fun create(intent: Intent): SharePresenter + fun create(shareIntentData: ShareIntentData): SharePresenter } private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.share(intent, roomIds) + sessionCoroutineScope.share(shareIntentData, roomIds) } @Composable @@ -73,13 +74,24 @@ class SharePresenter( } private fun CoroutineScope.share( - intent: Intent, + shareIntentData: ShareIntentData, roomIds: List, ) = launch { suspend { - val result = shareIntentHandler.handleIncomingShareIntent( - intent, - onUris = { filesToShare -> + val result = when (shareIntentData) { + is ShareIntentData.PlainText -> { + roomIds + .map { roomId -> + getJoinedRoom(roomId)?.liveTimeline?.sendMessage( + body = shareIntentData.content, + htmlBody = null, + intentionalMentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + is ShareIntentData.Uris -> { + val filesToShare = shareIntentData.uris if (filesToShare.isEmpty()) { false } else { @@ -90,6 +102,7 @@ class SharePresenter( filesToShare .map { fileToShare -> val result = mediaSender.sendMedia( + caption = shareIntentData.text, uri = fileToShare.uri, mimeType = fileToShare.mimeType, mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), @@ -113,19 +126,12 @@ class SharePresenter( } .all { it } } - }, - onPlainText = { text -> - roomIds - .map { roomId -> - getJoinedRoom(roomId)?.liveTimeline?.sendMessage( - body = text, - htmlBody = null, - intentionalMentions = emptyList(), - )?.isSuccess.orFalse() - } - .all { it } } - ) + } + + // Handle post-processing of shared data + onSharedData(shareIntentData) + if (!result) { error("Failed to handle incoming share intent") } diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index 83a32929ff..459fa423ec 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -8,13 +8,14 @@ package io.element.android.features.share.impl -import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -44,7 +45,7 @@ class DefaultShareEntryPointTest { override fun onDone(roomIds: List) = lambdaError() } val params = ShareEntryPoint.Params( - intent = Intent(), + shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) val result = entryPoint.createNode( parentNode = parentNode, @@ -53,7 +54,7 @@ class DefaultShareEntryPointTest { callback = callback, ) assertThat(result).isInstanceOf(ShareNode::class.java) - assertThat(result.plugins).contains(ShareNode.Inputs(params.intent)) + assertThat(result.plugins).contains(ShareNode.Inputs(params.shareIntentData)) assertThat(result.plugins).contains(callback) } } diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt deleted file mode 100644 index dbb9d1c704..0000000000 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.share.impl - -import android.content.Intent - -class FakeShareIntentHandler( - private val onIncomingShareIntent: suspend ( - Intent, - suspend (List) -> Boolean, - suspend (String) -> Boolean, - ) -> Boolean = { _, _, _ -> false }, -) : ShareIntentHandler { - override suspend fun handleIncomingShareIntent( - intent: Intent, - onUris: suspend (List) -> Boolean, - onPlainText: suspend (String) -> Boolean, - ): Boolean { - return onIncomingShareIntent(intent, onUris, onPlainText) - } -} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 0df1ed7bab..fee1278fce 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -8,12 +8,14 @@ package io.element.android.features.share.impl -import android.content.Intent import android.net.Uri import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.MatrixClient @@ -72,8 +74,17 @@ class SharePresenterTest { @Test fun `present - on room selected ok`() = runTest { + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _ -> Result.success(Unit) } + }, + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } val presenter = createSharePresenter( - shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true } + matrixClient = matrixClient, + shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -100,9 +111,7 @@ class SharePresenterTest { } val presenter = createSharePresenter( matrixClient = matrixClient, - shareIntentHandler = FakeShareIntentHandler { _, _, onText -> - onText(A_MESSAGE) - } + shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -131,16 +140,15 @@ class SharePresenterTest { ) val presenter = createSharePresenter( matrixClient = matrixClient, - shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> - onFile( - listOf( - ShareIntentHandler.UriToShare( - uri = Uri.parse("content://image.jpg"), - mimeType = MimeTypes.Jpeg, - ) + shareIntentData = ShareIntentData.Uris( + text = A_MESSAGE, + listOf( + UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, ) ) - }, + ), mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, ) moleculeFlow(RecompositionMode.Immediate) { @@ -159,20 +167,20 @@ class SharePresenterTest { } internal fun TestScope.createSharePresenter( - intent: Intent = Intent(), - shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), + shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), matrixClient: MatrixClient = FakeMatrixClient(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + onSharedData: OnSharedData = OnSharedData {}, ): SharePresenter { return SharePresenter( - intent = intent, + shareIntentData = shareIntentData, sessionCoroutineScope = this, - shareIntentHandler = shareIntentHandler, matrixClient = matrixClient, activeRoomsHolder = activeRoomsHolder, mediaSenderRoomFactory = mediaSenderRoomFactory, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + onSharedData = onSharedData, ) } diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts new file mode 100644 index 0000000000..e4a987c113 --- /dev/null +++ b/features/share/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.share.test" +} + +dependencies { + implementation(projects.features.share.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt new file mode 100644 index 0000000000..9fe04735fd --- /dev/null +++ b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.test + +import android.content.Intent +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.ShareIntentHandler + +class FakeShareIntentHandler( + private val onIncomingShareIntent: (Intent) -> ShareIntentData? = { null }, +) : ShareIntentHandler { + override fun handleIncomingShareIntent( + intent: Intent, + ): ShareIntentData? { + return onIncomingShareIntent(intent) + } +} From 69d63f1eacb73ae8dc5a89c0c9b8473ece32900b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:41:06 +0100 Subject: [PATCH 17/20] Update dependencyAnalysis to v3.6.1 (#6259) 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 bb2f62ee07..07b852ff96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ telephoto = "0.18.0" haze = "1.7.2" # Dependency analysis -dependencyAnalysis = "3.6.0" +dependencyAnalysis = "3.6.1" # DI metro = "0.11.2" From 721add707cc65a98a8e90a6f2284f9237badbe1e Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 3 Mar 2026 16:14:36 +0100 Subject: [PATCH 18/20] Simplify push notification flow by using locally stored values for pending pushes (#6258) * Create `PushRequest` in push history DB: this will be used to store requests for push notifications, either pending or completed ones. * Rename `WorkManagerRequest` to `WorkManagerRequestBuilder`: make its `build` method return a list of `WorkManagerRequestWrapper`, which can be used to enqueue normal or unique workers. * Rename `PerformDatabaseVacuumRequestBuilder` and adapt it to the new API. * Adjust other components using `WorkManagerRequest`. * Replace `SyncNotificationWorkManagerRequestBuilder` with `SyncPendingNotificationsRequestBuilder` and `FetchNotificationsWorker` with `FetchPendingNotificationsWorker`: this new pair of request builder and worker allow enqueuing requests for a session id and, once the worker runs, retrieve all the pending request data and use it to fetch the associated events. This simplifies quite a bit how this data had to be passed or grouped, since it's no longer necessary to do so * Add new methods to `PushHistoryService` to modify the `PushDatabase`: - insertOrUpdatePushRequest - insertOrUpdatePushRequests - getPendingPushRequests - removeOldPushRequests * Make `PushHandler` just handle incoming pushes: those will be inserted into the pending push request table in DB, then handled by the new worker. Once the process finished, a new `NotificationResultProcessor` will handle the results and what needs to be done with them (call ringing, displaying notifications, etc.) * Add `requestType` optional parameter to `WorkManagerScheduler.cancel` so we can decide to only cancel some kinds of requests. * Add migration to remove existing work manager requests for fetching notifications, since the previous worker class no longer exists. --- .../logout/impl/LogoutPresenterTest.kt | 5 +- features/migration/impl/build.gradle.kts | 1 + .../impl/migrations/AppMigration10.kt | 39 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 6 +- ...=> PerformDatabaseVacuumRequestBuilder.kt} | 12 +- .../impl/RustMatrixClientFactoryTest.kt | 4 +- .../push/api/push/NotificationEventRequest.kt | 20 - .../push/api/push/SyncOnNotifiableEvent.kt | 13 - libraries/push/impl/build.gradle.kts | 1 + .../impl/history/DefaultPushHistoryService.kt | 35 +- .../push/impl/history/PushHistoryService.kt | 36 +- .../DefaultNotifiableEventResolver.kt | 16 +- .../NotificationResolverQueue.kt | 125 ---- .../NotificationResultProcessor.kt | 238 +++++++ .../push/impl/push/DefaultPushHandler.kt | 267 ++------ .../impl/push/DefaultSyncOnNotifiableEvent.kt | 11 +- .../push/impl/push/SyncOnNotifiableEvent.kt | 14 + .../workmanager/FetchNotificationsWorker.kt | 191 ------ .../FetchPendingNotificationsWorker.kt | 239 +++++++ .../SyncNotificationWorkManagerRequest.kt | 68 -- .../SyncNotificationsWorkerDataConverter.kt | 129 ---- .../SyncPendingNotificationsRequestBuilder.kt | 51 ++ .../impl/src/main/sqldelight/databases/1.db | Bin 0 -> 8192 bytes .../impl/src/main/sqldelight/databases/2.db | Bin 0 -> 20480 bytes .../libraries/push/impl/db/PushHistory.sq | 1 - .../libraries/push/impl/db/PushRequest.sq | 24 + .../impl/src/main/sqldelight/migrations/1.sqm | 14 + .../impl/history/FakePushHistoryService.kt | 26 +- .../DefaultNotifiableEventResolverTest.kt | 69 +- .../DefaultNotificationResultProcessorTest.kt | 310 +++++++++ .../FakeNotifiableEventResolver.kt | 8 +- .../FakeNotificationResultProcessor.kt | 30 + ...equestFixture.kt => PushRequestFixture.kt} | 23 +- .../push/impl/push/DefaultPushHandlerTest.kt | 600 ++---------------- .../impl/push/SyncOnNotifiableEventTest.kt | 5 +- .../FetchNotificationWorkerTest.kt | 210 ------ .../FetchPendingNotificationWorkerTest.kt | 278 ++++++++ .../SyncNotificationWorkManagerRequestTest.kt | 98 --- ...cPendingNotificationsRequestBuilderTest.kt | 74 +++ .../workmanager/WorkerDataConverterTest.kt | 141 ---- .../FakeNotificationResolverQueue.kt | 24 - .../impl/src/main/sqldelight/migrations/0.sqm | 2 +- .../workmanager/api/WorkManagerRequest.kt | 15 - .../api/WorkManagerRequestBuilder.kt | 45 ++ .../workmanager/api/WorkManagerScheduler.kt | 16 +- .../impl/DefaultWorkManagerScheduler.kt | 32 +- .../impl/DefaultWorkManagerSchedulerTest.kt | 27 +- .../test/FakeWorkManagerScheduler.kt | 14 +- 48 files changed, 1715 insertions(+), 1892 deletions(-) create mode 100644 features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt rename libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/{PerformDatabaseVacuumWorkManagerRequest.kt => PerformDatabaseVacuumRequestBuilder.kt} (83%) delete mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt delete mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt delete mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt delete mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt delete mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt delete mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt create mode 100644 libraries/push/impl/src/main/sqldelight/databases/1.db create mode 100644 libraries/push/impl/src/main/sqldelight/databases/2.db create mode 100644 libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushRequest.sq create mode 100644 libraries/push/impl/src/main/sqldelight/migrations/1.sqm create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt rename libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/{NotificationEventRequestFixture.kt => PushRequestFixture.kt} (58%) delete mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt delete mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt delete mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt delete mode 100644 libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt delete mode 100644 libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt create mode 100644 libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 236b013837..274234a338 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -149,7 +150,7 @@ class LogoutPresenterTest { @Test fun `present - logout then confirm`() = runTest { - val cancelWorkManagerJobsLambda = lambdaRecorder {} + val cancelWorkManagerJobsLambda = lambdaRecorder { _, _ -> } val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda) val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler) moleculeFlow(RecompositionMode.Immediate) { @@ -238,7 +239,7 @@ class LogoutPresenterTest { internal fun createLogoutPresenter( matrixClient: MatrixClient = FakeMatrixClient(), encryptionService: EncryptionService = FakeEncryptionService(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = {}), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }), ): LogoutPresenter = LogoutPresenter( matrixClient = matrixClient, encryptionService = encryptionService, diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts index eb22d06399..a37c3be882 100644 --- a/features/migration/impl/build.gradle.kts +++ b/features/migration/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.workmanager.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt new file mode 100644 index 0000000000..26f033c604 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler + +/** + * Remove existing fetch notifications work manager requests since their format has changed. + */ +@ContributesIntoSet(AppScope::class) +class AppMigration10( + private val sessionStore: SessionStore, + private val workManagerScheduler: WorkManagerScheduler, +) : AppMigration { + override val order: Int = 10 + + override suspend fun migrate(isFreshInstall: Boolean) { + if (isFreshInstall) return + + val sessions = sessionStore.getAllSessions() + + for (session in sessions) { + workManagerScheduler.cancel( + sessionId = SessionId(session.userId), + requestType = WorkManagerRequestType.NOTIFICATION_SYNC + ) + } + } +} 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 124db3dd1f..1c87e73ba2 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 @@ -83,7 +83,7 @@ import io.element.android.libraries.matrix.impl.util.SessionPathsProvider import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService -import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumWorkManagerRequest +import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumRequestBuilder import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler @@ -832,8 +832,8 @@ class RustMatrixClient( if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return Timber.i("Scheduling periodic database vacuuming for session $sessionId") - val request = PerformDatabaseVacuumWorkManagerRequest(sessionId) - workManagerScheduler.submit(request) + val request = PerformDatabaseVacuumRequestBuilder(sessionId) + sessionCoroutineScope.launch { workManagerScheduler.submit(request) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumRequestBuilder.kt similarity index 83% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumRequestBuilder.kt index a8636eb5d9..fdaae25535 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumRequestBuilder.kt @@ -10,18 +10,18 @@ package io.element.android.libraries.matrix.impl.workmanager import androidx.work.Constraints import androidx.work.Data import androidx.work.PeriodicWorkRequest -import androidx.work.WorkRequest import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.impl.workmanager.VacuumDatabaseWorker.Companion.SESSION_ID_PARAM -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.api.workManagerTag import java.util.concurrent.TimeUnit -class PerformDatabaseVacuumWorkManagerRequest( +class PerformDatabaseVacuumRequestBuilder( private val sessionId: SessionId, -) : WorkManagerRequest { - override fun build(): Result> { +) : WorkManagerRequestBuilder { + override suspend fun build(): Result> { val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build() val workRequest = PeriodicWorkRequest.Builder( workerClass = VacuumDatabaseWorker::class, @@ -41,6 +41,6 @@ class PerformDatabaseVacuumWorkManagerRequest( ) .build() - return Result.success(listOf(workRequest)) + return Result.success(listOf(WorkManagerRequestWrapper(workRequest))) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 5e53d015e9..670430e23e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -19,7 +19,7 @@ import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -33,7 +33,7 @@ import java.io.File class RustMatrixClientFactoryTest { @Test fun test() = runTest { - val scheduleVacuumLambda = lambdaRecorder {} + val scheduleVacuumLambda = lambdaRecorder {} val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt deleted file mode 100644 index ff38c7a726..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.api.push - -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 - -data class NotificationEventRequest( - val sessionId: SessionId, - val roomId: RoomId, - val eventId: EventId, - val providerInfo: String, -) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt deleted file mode 100644 index bc7bf44ae2..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.api.push - -fun interface SyncOnNotifiableEvent { - suspend operator fun invoke(requests: List) -} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 705c1c713c..e8acda59da 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -97,6 +97,7 @@ sqldelight { databases { create("PushDatabase") { schemaOutputDirectory = File("src/main/sqldelight/databases") + verifyMigrations = true } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt index 2c8dc5480a..ab6f01e423 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -14,13 +14,16 @@ import android.os.PowerManager import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.db.PushHistory +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlin.time.Instant @ContributesBinding(AppScope::class) class DefaultPushHistoryService( @@ -31,7 +34,37 @@ class DefaultPushHistoryService( private val powerManager = context.getSystemService() private val packageName = context.packageName - override fun onPushReceived( + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { + return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } + } + + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { + return runCatchingExceptions { + pushDatabase.transaction { + for (request in pushRequests) { + pushDatabase.pushRequestQueries.insertPushRequest(request) + } + } + } + } + + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { + return runCatchingExceptions { + pushDatabase.transactionWithResult { + val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 + pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() + } + } + } + + override suspend fun removeOldPushRequests(sessionId: SessionId): Result { + return runCatchingExceptions { + val keepAmount = 100L + pushDatabase.pushRequestQueries.removeOldest(keepAmount) + } + } + + override fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt index 8096ad222e..3996924322 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -11,13 +11,16 @@ package io.element.android.libraries.push.impl.history import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.push.PushRequestStatus +import kotlin.time.Instant interface PushHistoryService { /** * Create a new push history entry. * Do not use directly, prefer using the extension functions. */ - fun onPushReceived( + fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -26,12 +29,33 @@ interface PushHistoryService { includeDeviceState: Boolean, comment: String?, ) + + /** + * Adds or replaces an existing [PushRequest] in the local database. + */ + suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result + + /** + * Replace a list of [PushRequest] in the database. + */ + suspend fun insertOrUpdatePushRequests(pushRequests: List): Result + + /** + * Gets [PushRequestStatus.PENDING] push requests from the local database for a [SessionId]. + * A [since] param can optionally be provided to only return those received after that date. + */ + suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> + + /** + * Removes the oldest push requests for a [SessionId]. + */ + suspend fun removeOldPushRequests(sessionId: SessionId): Result } fun PushHistoryService.onInvalidPushReceived( providerInfo: String, data: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = null, roomId = null, @@ -46,7 +70,7 @@ fun PushHistoryService.onUnableToRetrieveSession( eventId: EventId, roomId: RoomId, reason: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = eventId, roomId = roomId, @@ -62,7 +86,7 @@ fun PushHistoryService.onUnableToResolveEvent( roomId: RoomId, sessionId: SessionId, reason: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = eventId, roomId = roomId, @@ -78,7 +102,7 @@ fun PushHistoryService.onSuccess( roomId: RoomId, sessionId: SessionId, comment: String?, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = eventId, roomId = roomId, @@ -95,7 +119,7 @@ fun PushHistoryService.onSuccess( fun PushHistoryService.onDiagnosticPush( providerInfo: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = null, roomId = null, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index c0eb07b091..cf76b26e64 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -50,8 +50,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.ui.messages.toPlainText -import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent @@ -64,10 +64,10 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No /** * Result of resolving a batch of push events. * The outermost [Result] indicates whether the setup to resolve the events was successful. - * The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent]. + * The results for each push notification will be a map of [PushRequest] to [Result] of [ResolvedPushEvent]. * If the resolution of a specific event fails, the innermost [Result] will contain an exception. */ -typealias ResolvePushEventsResult = Result>> +typealias ResolvePushEventsResult = Result>> /** * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. @@ -78,7 +78,7 @@ typealias ResolvePushEventsResult = Result + notificationEventRequests: List ): ResolvePushEventsResult } @@ -96,15 +96,15 @@ class DefaultNotifiableEventResolver( ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, - notificationEventRequests: List + notificationEventRequests: List ): ResolvePushEventsResult { Timber.d("Queueing notifications: $notificationEventRequests") val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { return Result.failure(it) } - val ids = notificationEventRequests.groupBy { it.roomId } + val ids = notificationEventRequests.groupBy { RoomId(it.roomId) } .mapValues { (_, requests) -> - requests.map { it.eventId } + requests.map { EventId(it.eventId) } } // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event @@ -125,7 +125,7 @@ class DefaultNotifiableEventResolver( return Result.success( notificationEventRequests.associate { request -> - val notificationDataResult = notificationDataMap[request.eventId] + val notificationDataResult = notificationDataMap[EventId(request.eventId)] if (notificationDataResult == null) { request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}")) } else { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt deleted file mode 100644 index b40b3fe79f..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.notifications - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.SingleIn -import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent -import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest -import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter -import io.element.android.libraries.workmanager.api.WorkManagerScheduler -import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch -import timber.log.Timber -import kotlin.time.Duration.Companion.milliseconds - -interface NotificationResolverQueue { - val results: SharedFlow, Map>>> - suspend fun enqueue(request: NotificationEventRequest) -} - -/** - * This class is responsible for periodically batching notification requests and resolving them in a single call, - * so that we can avoid having to resolve each notification individually in the SDK. - */ -@OptIn(ExperimentalCoroutinesApi::class) -@SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) -class DefaultNotificationResolverQueue( - private val notifiableEventResolver: NotifiableEventResolver, - @AppCoroutineScope - private val appCoroutineScope: CoroutineScope, - private val workManagerScheduler: WorkManagerScheduler, - private val featureFlagService: FeatureFlagService, - private val workerDataConverter: SyncNotificationsWorkerDataConverter, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, -) : NotificationResolverQueue { - companion object { - private const val BATCH_WINDOW_MS = 250L - } - - private val requestQueue = Channel(capacity = 100) - - private var currentProcessingJob: Job? = null - - /** - * A flow that emits pairs of a list of notification event requests and a map of the resolved events. - * The map contains the original request as the key and the resolved event as the value. - */ - override val results = MutableSharedFlow, Map>>>() - - /** - * Enqueues a notification event request to be resolved. - * The request will be processed in batches, so it may not be resolved immediately. - * - * @param request The notification event request to enqueue. - */ - override suspend fun enqueue(request: NotificationEventRequest) { - // Cancel previous processing job if it exists, acting as a debounce operation - Timber.d("Cancelling job: $currentProcessingJob") - currentProcessingJob?.cancel() - - // Enqueue the request and start a delayed processing job - requestQueue.send(request) - currentProcessingJob = processQueue() - Timber.d("Starting processing job for request: $request") - } - - private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) { - delay(BATCH_WINDOW_MS.milliseconds) - - // If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items - // to process the existing queued items. - appCoroutineScope.launch { - val groupedRequestsById = buildList { - while (!requestQueue.isEmpty) { - requestQueue.receiveCatching().getOrNull()?.let(::add) - } - }.groupBy { it.sessionId } - - if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { - for ((sessionId, requests) in groupedRequestsById) { - workManagerScheduler.submit( - SyncNotificationWorkManagerRequest( - sessionId = sessionId, - notificationEventRequests = requests, - workerDataConverter = workerDataConverter, - buildVersionSdkIntProvider = buildVersionSdkIntProvider, - ) - ) - } - } else { - val sessionIds = groupedRequestsById.keys - for (sessionId in sessionIds) { - val requests = groupedRequestsById[sessionId].orEmpty() - Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}") - // Resolving the events in parallel should improve performance since each session id will query a different Client - launch { - // No need for a Mutex since the SDK already has one internally - val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty() - results.emit(requests to notifications) - } - } - } - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt new file mode 100644 index 0000000000..d799cc414d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.PushHistoryService +import io.element.android.libraries.push.impl.history.onSuccess +import io.element.android.libraries.push.impl.history.onUnableToResolveEvent +import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.libraries.push.impl.push.OnRedactedEventReceived +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val TAG = "NotifResultProcessor" + +interface NotificationResultProcessor { + suspend fun emit(results: Map>) + fun start() + fun stop() +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultNotificationResultProcessor( + private val pushHistoryService: PushHistoryService, + private val batteryOptimizationStore: MutableBatteryOptimizationStore, + private val fallbackNotificationFactory: FallbackNotificationFactory, + private val userPushStoreFactory: UserPushStoreFactory, + private val onRedactedEventReceived: OnRedactedEventReceived, + private val onNotifiableEventReceived: OnNotifiableEventReceived, + private val featureFlagService: FeatureFlagService, + private val syncOnNotifiableEvent: SyncOnNotifiableEvent, + private val elementCallEntryPoint: ElementCallEntryPoint, + private val notificationChannels: NotificationChannels, + @AppCoroutineScope private val coroutineScope: CoroutineScope, +) : NotificationResultProcessor { + private val resultFlow = MutableSharedFlow>>(extraBufferCapacity = Int.MAX_VALUE) + private var processJob: Job? = null + + override suspend fun emit(results: Map>) { + resultFlow.emit(results) + } + + override fun start() { + if (processJob?.isActive == true) { + Timber.tag(TAG).w("Is already processing, not starting again") + return + } + processJob = resultFlow + .onEach(::processResults) + .launchIn(coroutineScope) + } + + override fun stop() { + if (processJob?.isActive != true) { + Timber.tag(TAG).w("Is not processing, not stopping") + return + } + + processJob?.cancel() + processJob = null + } + + private suspend fun processResults(results: Map>) { + // TODO what happens with items that weren't reported back? + for ((request, result) in results) { + result.fold( + onSuccess = { + if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) { + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + reason = it.notifiableEvent.cause.orEmpty(), + ) + } else { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + comment = "Push handled successfully", + ) + } + }, + onFailure = { exception -> + if (exception is NotificationResolverException.EventFilteredOut) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + comment = "Push handled successfully but notification was filtered out", + ) + } else if (exception is NotificationResolverException.EventRedacted) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + comment = "Push handled successfully but event has been redacted", + ) + } else { + val reason = when (exception) { + is NotificationResolverException.EventNotFound -> "Event not found" + else -> "Unknown error: ${exception.message}" + } + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + reason = "$reason - Showing fallback notification", + ) + batteryOptimizationStore.showBatteryOptimizationBanner() + } + } + ) + } + + val events = mutableListOf() + val redactions = mutableListOf() + + @Suppress("LoopWithTooManyJumpStatements") + for ((request, result) in results) { + val event = result.recover { exception -> + // If the event could not be resolved, we create a fallback notification + when (exception) { + is NotificationResolverException.EventFilteredOut -> { + // Do nothing, we don't want to show a notification for filtered out events + null + } + is NotificationResolverException.EventRedacted -> { + // Do nothing, we don't want to show a notification for redacted events + null + } + else -> { + Timber.tag(TAG).e(exception, "Failed to resolve push event") + ResolvedPushEvent.Event( + fallbackNotificationFactory.create( + sessionId = SessionId(request.sessionId), + roomId = RoomId(request.roomId), + eventId = EventId(request.eventId), + cause = exception.message, + ) + ) + } + } + }.getOrNull() ?: continue + + val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId) + val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() + // If notifications are disabled for this session and device, we don't want to show the notification + // But if it's a ringing call, we want to show it anyway + val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent + if (!areNotificationsEnabled && !isRingingCall) continue + + // We categorise each result into either a NotifiableEvent or a Redaction + when (event) { + is ResolvedPushEvent.Event -> { + events.add(event.notifiableEvent) + } + is ResolvedPushEvent.Redaction -> { + redactions.add(event) + } + } + } + + // Process redactions of messages in background to not block operations with higher priority + if (redactions.isNotEmpty()) { + coroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) } + } + + // Find and process ringing call notifications separately + val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent } + for (ringingCallEvent in ringingCallEvents) { + Timber.tag(TAG).d("Ringing call event: $ringingCallEvent") + handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent) + } + + // Finally, process other notifications (messages, invites, generic notifications, etc.) + if (nonRingingCallEvents.isNotEmpty()) { + onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents) + } + + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { + syncOnNotifiableEvent(results.keys.toList()) + } + } + + private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { + Timber.i("## handleInternal() : Incoming call.") + elementCallEntryPoint.handleIncomingCall( + callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId), + eventId = notifiableEvent.eventId, + senderId = notifiableEvent.senderId, + roomName = notifiableEvent.roomName, + senderName = notifiableEvent.senderDisambiguatedDisplayName, + avatarUrl = notifiableEvent.roomAvatarUrl, + timestamp = notifiableEvent.timestamp, + expirationTimestamp = notifiableEvent.expirationTimestamp, + notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true), + textContent = notifiableEvent.description, + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 0053a18838..5ed4223616 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -11,42 +11,28 @@ package io.element.android.libraries.push.impl.push import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn -import io.element.android.features.call.api.CallType -import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.matrix.api.exception.NotificationResolverException -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.history.onDiagnosticPush import io.element.android.libraries.push.impl.history.onInvalidPushReceived -import io.element.android.libraries.push.impl.history.onSuccess -import io.element.android.libraries.push.impl.history.onUnableToResolveEvent import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession -import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory -import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue -import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService -import kotlinx.coroutines.CoroutineScope +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import timber.log.Timber private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @@ -54,173 +40,20 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultPushHandler( - private val onNotifiableEventReceived: OnNotifiableEventReceived, - private val onRedactedEventReceived: OnRedactedEventReceived, private val incrementPushDataStore: IncrementPushDataStore, - private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, - private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val buildMeta: BuildMeta, private val diagnosticPushHandler: DiagnosticPushHandler, - private val elementCallEntryPoint: ElementCallEntryPoint, - private val notificationChannels: NotificationChannels, private val pushHistoryService: PushHistoryService, - private val resolverQueue: NotificationResolverQueue, - @AppCoroutineScope - private val appCoroutineScope: CoroutineScope, - private val fallbackNotificationFactory: FallbackNotificationFactory, - private val syncOnNotifiableEvent: SyncOnNotifiableEvent, - private val featureFlagService: FeatureFlagService, + private val userPushStoreFactory: UserPushStoreFactory, private val analyticsService: AnalyticsService, + private val systemClock: SystemClock, + private val workManagerScheduler: WorkManagerScheduler, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + resultProcessor: NotificationResultProcessor, ) : PushHandler { init { - processPushEventResults() - } - - /** - * Process the push notification event results emitted by the [resolverQueue]. - */ - private fun processPushEventResults() { - resolverQueue.results - .map { (requests, resolvedEvents) -> - for (request in requests) { - // Log the result of the push notification event - val result = resolvedEvents[request] - if (result == null) { - pushHistoryService.onUnableToResolveEvent( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - reason = "Push not handled: no result found for request", - ) - } else { - result.fold( - onSuccess = { - if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) { - pushHistoryService.onUnableToResolveEvent( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - reason = it.notifiableEvent.cause.orEmpty(), - ) - } else { - pushHistoryService.onSuccess( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - comment = "Push handled successfully", - ) - } - }, - onFailure = { exception -> - if (exception is NotificationResolverException.EventFilteredOut) { - pushHistoryService.onSuccess( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - comment = "Push handled successfully but notification was filtered out", - ) - } else if (exception is NotificationResolverException.EventRedacted) { - pushHistoryService.onSuccess( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - comment = "Push handled successfully but event has been redacted", - ) - } else { - val reason = when (exception) { - is NotificationResolverException.EventNotFound -> "Event not found" - else -> "Unknown error: ${exception.message}" - } - pushHistoryService.onUnableToResolveEvent( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - reason = "$reason - Showing fallback notification", - ) - mutableBatteryOptimizationStore.showBatteryOptimizationBanner() - } - } - ) - } - } - - val events = mutableListOf() - val redactions = mutableListOf() - - @Suppress("LoopWithTooManyJumpStatements") - for ((request, result) in resolvedEvents) { - val event = result.recover { exception -> - // If the event could not be resolved, we create a fallback notification - when (exception) { - is NotificationResolverException.EventFilteredOut -> { - // Do nothing, we don't want to show a notification for filtered out events - null - } - is NotificationResolverException.EventRedacted -> { - // Do nothing, we don't want to show a notification for redacted events - null - } - else -> { - Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event") - ResolvedPushEvent.Event( - fallbackNotificationFactory.create( - sessionId = request.sessionId, - roomId = request.roomId, - eventId = request.eventId, - cause = exception.message, - ) - ) - } - } - }.getOrNull() ?: continue - - val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId) - val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() - // If notifications are disabled for this session and device, we don't want to show the notification - // But if it's a ringing call, we want to show it anyway - val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent - if (!areNotificationsEnabled && !isRingingCall) continue - - // We categorise each result into either a NotifiableEvent or a Redaction - when (event) { - is ResolvedPushEvent.Event -> { - events.add(event.notifiableEvent) - } - is ResolvedPushEvent.Redaction -> { - redactions.add(event) - } - } - } - - // Process redactions of messages in background to not block operations with higher priority - if (redactions.isNotEmpty()) { - appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) } - } - - // Find and process ringing call notifications separately - val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent } - for (ringingCallEvent in ringingCallEvents) { - Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent") - handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent) - } - - // Finally, process other notifications (messages, invites, generic notifications, etc.) - if (nonRingingCallEvents.isNotEmpty()) { - onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents) - } - - if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { - syncOnNotifiableEvent(requests) - } - } - .launchIn(appCoroutineScope) + resultProcessor.start() } /** @@ -233,9 +66,7 @@ class DefaultPushHandler( // Start measuring how long it takes to display a notification from when the push is received Timber.d("Calculating push-to-notification for event ${pushData.eventId}") val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value)) - if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent) - } + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent) Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") if (buildMeta.lowPrivacyLoggingEnabled) { @@ -282,34 +113,56 @@ class DefaultPushHandler( return } - appCoroutineScope.launch { - val notificationEventRequest = NotificationEventRequest( - sessionId = userId, - roomId = pushData.roomId, - eventId = pushData.eventId, - providerInfo = providerInfo, + val areNotificationsEnabled = userPushStoreFactory.getOrCreate(userId).getNotificationEnabledForDevice().first() + if (!areNotificationsEnabled) { + Timber.w("Push notification received when push notifications are disabled.") + return + } + + val pushRequest = PushRequest( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = pushData.eventId.value, + roomId = pushData.roomId.value, + sessionId = userId.value, + status = PushRequestStatus.PENDING.value, + retries = 0L, + ) + + Timber.d("Queueing notification: $pushRequest") + pushHistoryService.insertOrUpdatePushRequest(pushRequest) + + if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) { + Timber.d("No pending worker for push notifications found") + workManagerScheduler.submit( + SyncPendingNotificationsRequestBuilder( + sessionId = userId, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + ) ) - Timber.d("Queueing notification: $notificationEventRequest") - resolverQueue.enqueue(notificationEventRequest) } } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } } - - private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { - Timber.i("## handleInternal() : Incoming call.") - elementCallEntryPoint.handleIncomingCall( - callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId), - eventId = notifiableEvent.eventId, - senderId = notifiableEvent.senderId, - roomName = notifiableEvent.roomName, - senderName = notifiableEvent.senderDisambiguatedDisplayName, - avatarUrl = notifiableEvent.roomAvatarUrl, - timestamp = notifiableEvent.timestamp, - expirationTimestamp = notifiableEvent.expirationTimestamp, - notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true), - textContent = notifiableEvent.description, - ) - } +} + +/** + * Represents the status of a [PushRequest]. + */ +enum class PushRequestStatus(val value: Long) { + /** + * Either it was enqueued, and we never tried to fetch it, or it failed with a recoverable error. + */ + PENDING(0), + + /** + * The event for the [PushRequest] was fetched successfully. + */ + SUCCESS(1), + + /** + * Fetching the event for the [PushRequest] failed with an unrecoverable error, and it won't be retried. + */ + FAILED(2), } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt index 8b8e671fcc..f3a52f9e15 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt @@ -14,8 +14,9 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClientProvider -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -29,7 +30,7 @@ class DefaultSyncOnNotifiableEvent( private val appForegroundStateService: AppForegroundStateService, private val dispatchers: CoroutineDispatchers, ) : SyncOnNotifiableEvent { - override suspend operator fun invoke(requests: List) = withContext(dispatchers.io) { + override suspend operator fun invoke(requests: List) = withContext(dispatchers.io) { if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) { return@withContext } @@ -41,8 +42,8 @@ class DefaultSyncOnNotifiableEvent( Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}") for ((sessionId, events) in eventsBySession) { - val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue - val roomIds = events.map { it.roomId }.distinct() + val client = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrNull() ?: continue + val roomIds = events.map { RoomId(it.roomId) }.distinct() client.roomListService.subscribeToVisibleRooms(roomIds) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt new file mode 100644 index 0000000000..5d584986f1 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import io.element.android.libraries.push.impl.db.PushRequest + +fun interface SyncOnNotifiableEvent { + suspend operator fun invoke(requests: List) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt deleted file mode 100644 index 23220cf366..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.binding -import io.element.android.features.networkmonitor.api.NetworkMonitor -import io.element.android.features.networkmonitor.api.NetworkStatus -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.di.annotations.ApplicationContext -import io.element.android.libraries.matrix.api.auth.SessionRestorationException -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent -import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver -import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue -import io.element.android.libraries.workmanager.api.WorkManagerScheduler -import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory -import io.element.android.libraries.workmanager.api.di.WorkerKey -import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.finishLongRunningTransaction -import io.element.android.services.analytics.api.recordTransaction -import io.element.android.services.analyticsproviders.api.AnalyticsTransaction -import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeoutOrNull -import timber.log.Timber -import kotlin.time.Duration.Companion.seconds - -@AssistedInject -class FetchNotificationsWorker( - @Assisted params: WorkerParameters, - @ApplicationContext private val context: Context, - private val networkMonitor: NetworkMonitor, - private val eventResolver: NotifiableEventResolver, - private val queue: NotificationResolverQueue, - private val workManagerScheduler: WorkManagerScheduler, - private val syncOnNotifiableEvent: SyncOnNotifiableEvent, - private val workerDataConverter: SyncNotificationsWorkerDataConverter, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, - private val analyticsService: AnalyticsService, -) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - Timber.d("FetchNotificationsWorker started") - val requests = workerDataConverter.deserialize(inputData) ?: return Result.failure() - - val networkTimeoutSpans = requests.mapNotNull { request -> - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value)) - parent?.startChild("Waiting for network connectivity", "await_network") - } - - // Wait for network to be available, but not more than 10 seconds - val hasNetwork = withTimeoutOrNull(10.seconds) { - networkMonitor.connectivity.first { it == NetworkStatus.Connected } - } != null - - networkTimeoutSpans.finish() - - // If there is a problem with the updated network values, report it and retry if needed - if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) { - return Result.retry() - } - - val pendingAnalyticTransactions = requests.mapNotNull { request -> - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value)) - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(request.eventId.value)) - val transactionName = "WorkManager to event fetched" - parent?.startChild(transactionName)?.let { request.eventId to it } - }.toMap() - - val failedSyncForSessions = mutableMapOf() - - val groupedRequests = requests.groupBy { it.sessionId }.toMutableMap() - for ((sessionId, notificationRequests) in groupedRequests) { - Timber.d("Processing notification requests for session $sessionId") - eventResolver.resolveEvents(sessionId, notificationRequests) - .fold( - onSuccess = { result -> - for ((_, transaction) in pendingAnalyticTransactions) { - transaction.finish() - } - // Update the resolved results in the queue - (queue.results as MutableSharedFlow).emit(requests to result) - }, - onFailure = { - for ((_, transaction) in pendingAnalyticTransactions) { - transaction.attachError(it) - transaction.finish() - } - failedSyncForSessions[sessionId] = it - Timber.e(it, "Failed to resolve notification events for session $sessionId") - } - ) - } - - // If there were failures for whole sessions, we retry all their requests - if (failedSyncForSessions.isNotEmpty()) { - @Suppress("LoopWithTooManyJumpStatements") - for ((failedSessionId, exception) in failedSyncForSessions) { - if (exception.cause is SessionRestorationException) { - Timber.e(exception, "Session $failedSessionId could not be restored, not retrying notification fetching") - groupedRequests.remove(failedSessionId) - continue - } - val requestsToRetry = groupedRequests[failedSessionId] ?: continue - - for (request in requestsToRetry) { - val failedTransaction = pendingAnalyticTransactions[request.eventId] - failedTransaction?.attachError(exception) - failedTransaction?.finish() - - val eventId = request.eventId.value - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) - // Since we're retrying, start a new transaction - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) - } - - Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId") - workManagerScheduler.submit( - SyncNotificationWorkManagerRequest( - sessionId = failedSessionId, - notificationEventRequests = requestsToRetry, - workerDataConverter = workerDataConverter, - buildVersionSdkIntProvider = buildVersionSdkIntProvider, - ) - ) - } - } - - Timber.d("Notifications processed successfully") - - analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") { - performOpportunisticSyncIfNeeded(groupedRequests) - } - - return Result.success() - } - - private fun reportConnectivityError(requests: List, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean { - return if (!hasNetwork || isNetworkBlocked) { - for (request in requests) { - val eventId = request.eventId.value - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) { - it.putExtraData("has_network_connection", hasNetwork.toString()) - it.putExtraData("is_network_blocked", isNetworkBlocked.toString()) - } - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) - // Since we're retrying, start a new transaction - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) - } - Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked") - true - } else { - false - } - } - - private suspend fun performOpportunisticSyncIfNeeded( - groupedRequests: Map>, - ) { - for ((sessionId, notificationRequests) in groupedRequests) { - runCatchingExceptions { - syncOnNotifiableEvent(notificationRequests) - }.onFailure { - Timber.e(it, "Failed to sync on notifiable events for session $sessionId") - } - } - } - - @ContributesIntoMap(AppScope::class, binding = binding>()) - @WorkerKey(FetchNotificationsWorker::class) - @AssistedFactory - interface Factory : MetroWorkerFactory.WorkerInstanceFactory -} - -private fun Collection.finish() = forEach { it.finish() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt new file mode 100644 index 0000000000..fd9008839f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.binding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.auth.SessionRestorationException +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.exception.isNetworkError +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.PushHistoryService +import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor +import io.element.android.libraries.push.impl.push.PushRequestStatus +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.finishLongRunningTransaction +import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@AssistedInject +class FetchPendingNotificationsWorker( + @Assisted private val params: WorkerParameters, + @ApplicationContext private val context: Context, + private val pushHistoryService: PushHistoryService, + private val networkMonitor: NetworkMonitor, + private val eventResolver: NotifiableEventResolver, + private val syncOnNotifiableEvent: SyncOnNotifiableEvent, + private val resultProcessor: NotificationResultProcessor, + private val analyticsService: AnalyticsService, + private val systemClock: SystemClock, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + Timber.d("FetchNotificationsWorker started") + // RunCatching for test in debug mode + val sessionId = runCatchingExceptions { + inputData.getString(SyncPendingNotificationsRequestBuilder.SESSION_ID)?.let(::SessionId) + }.getOrNull() ?: return Result.failure() + + // Fetch pending requests in the last 24 hours + val fetchSince = Instant.fromEpochMilliseconds(systemClock.epochMillis()).minus(1.days) + val requests = pushHistoryService.getPendingPushRequests(sessionId, fetchSince).getOrNull() ?: return Result.failure() + + pushHistoryService.removeOldPushRequests(sessionId).onFailure { + Timber.e(it, "Could not remove outdated push requests") + } + + if (requests.isEmpty()) { + Timber.d("No pending notifications to fetch, returning early") + return Result.success() + } + + checkNetworkConnection(requests)?.let { failure -> return failure } + + Timber.d("Fetching ${requests.size} push requests") + + val pendingAnalyticTransactions = requests.mapNotNull { request -> + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId)) + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(request.eventId)) + val transactionName = "WorkManager to event fetched" + parent?.startChild(transactionName)?.let { request.eventId to it } + }.toMap() + + Timber.d("Processing notification requests for session $sessionId") + val results = eventResolver.resolveEvents(sessionId, requests) + .fold( + onSuccess = { results -> + for ((_, transaction) in pendingAnalyticTransactions) { + transaction.finish() + } + // Update the resolved results in the queue + resultProcessor.emit(results) + + results + }, + onFailure = { + // This is a failure at the fetch notification setup, not a failure for a single fetch notification operation + return handleSetupError(sessionId, requests, pendingAnalyticTransactions, it) + } + ) + + val updatedRequests = mutableListOf() + for (request in requests) { + val result = results[request] ?: continue + result.fold( + onSuccess = { updatedRequests.add(request.copy(status = PushRequestStatus.SUCCESS.value)) }, + onFailure = { exception -> + if (exception is ClientException && exception.isNetworkError()) { + // Reset to pending so we can retry it later + updatedRequests.add(request.copy(status = PushRequestStatus.PENDING.value)) + } else { + updatedRequests.add(request.copy(status = PushRequestStatus.FAILED.value)) + } + } + ) + } + + Timber.d("Notifications processed successfully") + + pushHistoryService.insertOrUpdatePushRequests(updatedRequests) + + analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") { + performOpportunisticSyncIfNeeded(mapOf(sessionId to requests)) + } + + return if (updatedRequests.any { it.status == PushRequestStatus.PENDING.value }) Result.retry() else Result.success() + } + + private suspend fun performOpportunisticSyncIfNeeded( + groupedRequests: Map>, + ) { + for ((sessionId, notificationRequests) in groupedRequests) { + runCatchingExceptions { + syncOnNotifiableEvent(notificationRequests) + }.onFailure { + Timber.e(it, "Failed to sync on notifiable events for session $sessionId") + } + } + } + + private suspend fun checkNetworkConnection(requests: List): Result? { + val networkTimeoutSpans = requests.mapNotNull { request -> + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId)) + parent?.startChild("Waiting for network connectivity", "await_network") + } + + // Wait for network to be available, but not more than 10 seconds + val hasNetwork = withTimeoutOrNull(10.seconds) { + networkMonitor.connectivity.first { it == NetworkStatus.Connected } + } != null + + networkTimeoutSpans.finish() + + // If there is a problem with the updated network values, report it and retry if needed + if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) { + pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> + request.copy(retries = request.retries + 1) + }) + return Result.retry() + } + + return null + } + + private fun reportConnectivityError(requests: List, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean { + return if (!hasNetwork || isNetworkBlocked) { + for (request in requests) { + val eventId = request.eventId + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) { + it.putExtraData("has_network_connection", hasNetwork.toString()) + it.putExtraData("is_network_blocked", isNetworkBlocked.toString()) + } + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) + // Since we're retrying, start a new transaction + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) + } + Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked") + true + } else { + false + } + } + + private suspend fun handleSetupError( + sessionId: SessionId, + requests: List, + pendingAnalyticTransactions: Map, + throwable: Throwable, + ): Result { + for ((_, transaction) in pendingAnalyticTransactions) { + transaction.attachError(throwable) + transaction.finish() + } + + // If there were failures on the setup step and they weren't recoverable, update the requests and fail + if (throwable.cause is SessionRestorationException) { + Timber.e(throwable, "Session $sessionId could not be restored, not retrying notification fetching") + pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> + request.copy(status = PushRequestStatus.FAILED.value) + }) + return Result.failure() + } + + // If the failure is recoverable, retry + for (request in requests) { + val failedTransaction = pendingAnalyticTransactions[request.eventId] + failedTransaction?.attachError(throwable) + failedTransaction?.finish() + + val eventId = request.eventId + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) + // Since we're retrying, start a new transaction + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) + } + + Timber.d("Re-scheduling ${requests.size} failed notification requests for session $sessionId") + + pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> + request.copy(retries = request.retries + 1) + }) + + return Result.retry() + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(FetchPendingNotificationsWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} + +private fun Collection.finish() = forEach { it.finish() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt deleted file mode 100644 index 50ef28903c..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import android.os.Build -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkRequest -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.workmanager.api.WorkManagerRequest -import io.element.android.libraries.workmanager.api.WorkManagerRequestType -import io.element.android.libraries.workmanager.api.workManagerTag -import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import timber.log.Timber -import java.security.InvalidParameterException - -class SyncNotificationWorkManagerRequest( - private val sessionId: SessionId, - private val notificationEventRequests: List, - private val workerDataConverter: SyncNotificationsWorkerDataConverter, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, -) : WorkManagerRequest { - override fun build(): Result> { - if (notificationEventRequests.isEmpty()) { - return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty")) - } - Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId") - return workerDataConverter.serialize(notificationEventRequests).map { dataList -> - dataList.map { data -> - OneTimeWorkRequestBuilder() - .setInputData(data) - .apply { - // Expedited workers aren't needed on Android 12 or lower: - // They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway - // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat - if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - } - } - .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) - // TODO investigate using this instead of the resolver queue - // .setInputMerger() - .build() - } - } - } - - @Serializable - data class Data( - @SerialName("session_id") - val sessionId: String, - @SerialName("room_id") - val roomId: String, - @SerialName("event_id") - val eventId: String, - @SerialName("provider_info") - val providerInfo: String, - ) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt deleted file mode 100644 index 46b7d760c0..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import androidx.work.Data -import androidx.work.workDataOf -import dev.zacsweers.metro.Inject -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.core.extensions.mapCatchingExceptions -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.push.api.push.NotificationEventRequest -import timber.log.Timber - -@Inject -class SyncNotificationsWorkerDataConverter( - private val json: JsonProvider, -) { - fun serialize(notificationEventRequests: List): Result> { - // First try to serialize all requests at once. In the vast majority of cases this will work. - return serializeRequests(notificationEventRequests) - .map { listOf(it) } - .recoverCatching { t -> - if (t is DataForWorkManagerIsTooBig) { - // Perform serialization on sublists, workDataOf have failed because of size limit - Timber.w(t, "Failed to serialize ${notificationEventRequests.size} notification requests, split the requests per room.") - // Group the requests per rooms - val requestsSortedPerRoom = notificationEventRequests.groupBy { it.roomId }.values - // Build a list of sublist with size at most CHUNK_SIZE, and with all rooms kept together - buildList { - val currentChunk = mutableListOf() - for (requests in requestsSortedPerRoom) { - if (currentChunk.size + requests.size <= CHUNK_SIZE) { - // Can add the whole room requests to the current chunk - currentChunk.addAll(requests) - } else { - // Add the current chunk - add(currentChunk.toList()) - // Start a new chunk with the current room requests - currentChunk.clear() - // If a room has more requests than CHUNK_SIZE, we need to split them - requests.chunked(CHUNK_SIZE) { chunk -> - if (chunk.size == CHUNK_SIZE) { - add(chunk.toList()) - } else { - currentChunk.addAll(chunk) - } - } - } - } - // Add any remaining requests - add(currentChunk.toList()) - } - .filter { it.isNotEmpty() } - .also { - Timber.d("Split notification requests into ${it.size} chunks for WorkManager serialization") - it.forEach { requests -> - Timber.d(" - Chunk with ${requests.size} requests") - } - } - .mapNotNull { serializeRequests(it).getOrNull() } - } else { - throw t - } - } - } - - private fun serializeRequests(notificationEventRequests: List): Result { - return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) } - .onFailure { - Timber.e(it, "Failed to serialize notification requests") - } - .mapCatchingExceptions { str -> - // Note: workDataOf can fail if the data is too large - try { - workDataOf(REQUESTS_KEY to str) - } catch (_: IllegalStateException) { - throw DataForWorkManagerIsTooBig() - } - } - } - - fun deserialize(data: Data): List? { - val rawRequestsJson = data.getString(REQUESTS_KEY) ?: return null - return runCatchingExceptions { - json().decodeFromString>(rawRequestsJson).map { it.toRequest() } - }.fold( - onSuccess = { - Timber.d("Deserialized ${it.size} requests") - it - }, - onFailure = { - Timber.e(it, "Failed to deserialize notification requests") - null - } - ) - } - - companion object { - private const val REQUESTS_KEY = "requests" - internal const val CHUNK_SIZE = 20 - } -} - -private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data { - return SyncNotificationWorkManagerRequest.Data( - sessionId = sessionId.value, - roomId = roomId.value, - eventId = eventId.value, - providerInfo = providerInfo, - ) -} - -private fun SyncNotificationWorkManagerRequest.Data.toRequest(): NotificationEventRequest { - return NotificationEventRequest( - sessionId = SessionId(sessionId), - roomId = RoomId(roomId), - eventId = EventId(eventId), - providerInfo = providerInfo, - ) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt new file mode 100644 index 0000000000..5aa40cadb5 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import android.os.Build +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.workDataOf +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider + +class SyncPendingNotificationsRequestBuilder( + private val sessionId: SessionId, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : WorkManagerRequestBuilder { + companion object { + const val SESSION_ID = "session_id" + } + + override suspend fun build(): Result> { + val type = WorkManagerWorkerType.Unique( + name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC), + policy = ExistingWorkPolicy.APPEND_OR_REPLACE, + ) + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(SESSION_ID to sessionId.value)) + .apply { + // Expedited workers aren't needed on Android 12 or lower: + // They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway + // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } + .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) + .build() + return Result.success(listOf(WorkManagerRequestWrapper(request, type))) + } +} diff --git a/libraries/push/impl/src/main/sqldelight/databases/1.db b/libraries/push/impl/src/main/sqldelight/databases/1.db new file mode 100644 index 0000000000000000000000000000000000000000..fa2978d82d79b0c918bd78f3f4b6a75f3af0392c GIT binary patch literal 8192 zcmeI#O-sZu5C-665rhR%PaYlEi-LIZ54fvA3#Dp};I-OWw$L_`#DYih?Em%XbeDzg zLA;8*Lqa>5e86*?`@6J?OmpX(BGQ@KQ(CKYBBhj;Ig+`KKHpj=^6m5ALaWoyuN~Q? zclVVH1Oy-e0SG_<0uX=z1Rwwb2teR(2)x&N`(QHBABVAcs`>UcEUv2%oqyZ-9bM#R zY7N=xEH$)IprbKKyvV7`Li*|?v*ya=l-*d$9@6w=Rrao{O7=-RcVx|@)CP6#xQ$7< zEUiv>=bFJn;1DX;4%QY$m@&6G2UmAo{_yy-YZ|$JdHMLb@6Dy@M?e4q5P$##AOHaf QKmY;|fB*y_@Q($)0n9v3=l}o! literal 0 HcmV?d00001 diff --git a/libraries/push/impl/src/main/sqldelight/databases/2.db b/libraries/push/impl/src/main/sqldelight/databases/2.db new file mode 100644 index 0000000000000000000000000000000000000000..42e0dfaa90e127975ff3d392e2b41c9c04b7eee1 GIT binary patch literal 20480 zcmeI%L2KJE6bEp*N!JmUwYMDiz?TNf*ciLdO|;F-bvws}_7uEXrG~_6EhTIW2Hket zKGeQdkCPR*C@E!^!65tvcBDK>&ihF@2#?-Jg%)%st7)d`1>0es$6gUJ##-juG}le3 z+wGf%`ShInebp9w`s=#8ea+h4N9^&J?cd#>rh)5)*XkY!NjBc17)azdMfga;|7Fdp&~assPk=rDG}w5t}A zhWQ>TJ8S=H(%S0zzW4o!&OS^;J=xnrX<2=Cs_o{1X|Nx0J7d#_b~|Lsg&8%>%#kjP zQ~ri06d$G(A4k#NB3#LNk&7xU&m^V%#Ke~3VlGM@<~Fn#QAs&n`r`J7JDtumwJ$`O z2qhB(1k8(OS)7x>K{i9`0EEJB|7*|FHhMb?e(seM5l&1Rwwb2tWV=5P$##AOHafKww=3I(1?=|F7%vA} ? ORDER BY pushDate ASC; + +insertPushRequest: +INSERT OR REPLACE INTO PushRequest VALUES ?; + +removeAll: +DELETE FROM PushRequest; + +removeOldest: +DELETE FROM PushRequest WHERE rowid NOT IN (SELECT rowid FROM PushRequest ORDER BY pushDate DESC LIMIT ?); diff --git a/libraries/push/impl/src/main/sqldelight/migrations/1.sqm b/libraries/push/impl/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000000..4f9edb2b08 --- /dev/null +++ b/libraries/push/impl/src/main/sqldelight/migrations/1.sqm @@ -0,0 +1,14 @@ +-- Migrate DB from version 1 + +CREATE TABLE PushRequest ( + pushDate INTEGER NOT NULL, + providerInfo TEXT NOT NULL, + eventId TEXT NOT NULL, + roomId TEXT NOT NULL, + sessionId TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + retries INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(sessionId, eventId) +); + +CREATE INDEX PushRequestSessionAndStatus ON PushRequest (sessionId, status); diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt index aac8f8f26d..aa9612717a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -11,7 +11,9 @@ package io.element.android.libraries.push.impl.history import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.tests.testutils.lambda.lambdaError +import kotlin.time.Instant class FakePushHistoryService( private val onPushReceivedResult: ( @@ -22,9 +24,13 @@ class FakePushHistoryService( Boolean, Boolean, String? - ) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() } + ) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() }, + private val enqueuePushRequest: (PushRequest) -> Result = { lambdaError() }, + private val replacePushRequests: (List) -> Result = { lambdaError() }, + private val getPendingPushRequests: (SessionId, Instant?) -> Result> = { _, _ -> lambdaError() }, + private val removeOldPushRequests: (SessionId) -> Result = { lambdaError() }, ) : PushHistoryService { - override fun onPushReceived( + override fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -43,4 +49,20 @@ class FakePushHistoryService( comment ) } + + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { + return enqueuePushRequest.invoke(pushRequest) + } + + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { + return replacePushRequests.invoke(pushRequests) + } + + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { + return getPendingPushRequests.invoke(sessionId, since) + } + + override suspend fun removeOldPushRequests(sessionId: SessionId): Result { + return removeOldPushRequests.invoke(sessionId) + } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index ba58db2a3c..19ef74d0b9 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -47,9 +47,10 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.aNotificationData import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser -import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent @@ -71,7 +72,7 @@ class DefaultNotifiableEventResolverTest { @Test fun `resolve event no session`() = runTest { val sut = createDefaultNotifiableEventResolver(notificationService = null) - val result = sut.resolveEvents(A_SESSION_ID, listOf(NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase"))) + val result = sut.resolveEvents(A_SESSION_ID, listOf(aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase"))) assertThat(result.isFailure).isTrue() } @@ -80,7 +81,7 @@ class DefaultNotifiableEventResolverTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.failure(AN_EXCEPTION) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.isFailure).isTrue() } @@ -90,7 +91,7 @@ class DefaultNotifiableEventResolverTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION))) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.isFailure).isTrue() } @@ -109,7 +110,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") @@ -133,7 +134,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true) @@ -161,7 +162,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") @@ -189,7 +190,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") @@ -211,7 +212,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Audio") @@ -233,7 +234,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Video") @@ -255,7 +256,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Voice message") @@ -277,7 +278,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Image") @@ -299,7 +300,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Sticker") @@ -321,7 +322,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "File") @@ -343,7 +344,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Location") @@ -365,7 +366,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Notice") @@ -387,7 +388,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "* Bob is happy") @@ -409,7 +410,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Poll: A question") @@ -432,7 +433,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } @@ -451,7 +452,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -490,7 +491,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -527,7 +528,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -565,7 +566,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -605,7 +606,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -642,7 +643,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } @@ -654,7 +655,7 @@ class DefaultNotifiableEventResolverTest { mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted))) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( FallbackNotifiableEvent( @@ -680,7 +681,7 @@ class DefaultNotifiableEventResolverTest { mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound)) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)).isEqualTo(Result.failure(NotificationResolverException.EventNotFound)) } @@ -698,7 +699,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( NotifiableMessageEvent( @@ -766,7 +767,7 @@ class DefaultNotifiableEventResolverTest { ) ) callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) } - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @@ -791,7 +792,7 @@ class DefaultNotifiableEventResolverTest { redactedEventId = AN_EVENT_ID_2, reason = A_REDACTION_REASON, ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @@ -810,7 +811,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } @@ -857,13 +858,13 @@ class DefaultNotifiableEventResolverTest { mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content))) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } - private fun Result>>.getEvent( - request: NotificationEventRequest + private fun Result>>.getEvent( + request: PushRequest ): Result? { return getOrNull()?.get(request) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt new file mode 100644 index 0000000000..5a0d95c017 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.test.FakeElementCallEntryPoint +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.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.FakePushHistoryService +import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived +import io.element.android.libraries.push.impl.push.FakeOnRedactedEventReceived +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNotificationResultProcessorTest { + @Test + fun `when not able to resolve the event, the banner to disable battery optimization will be displayed`() { + `test notification resolver failure`( + notificationResolveResult = { requests: List -> + Result.success( + requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) } + ) + }, + shouldSetOptimizationBatteryBanner = true, + ) + } + + private fun `test notification resolver failure`( + notificationResolveResult: (List) -> Result>>, + shouldSetOptimizationBatteryBanner: Boolean, + ) { + runTest { + val notifiableEventResult = + lambdaRecorder, Result>>> { _, requests -> + notificationResolveResult(requests) + } + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val showBatteryOptimizationBannerResult = lambdaRecorder {} + val processor = createDefaultNotificationResultProcessor( + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult, + ), + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.failure(IllegalStateException("boom")))) + } + + notifiableEventResult.assertions() + .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() + .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any()) + showBatteryOptimizationBannerResult.assertions().let { + if (shouldSetOptimizationBatteryBanner) { + it.isCalledOnce() + } else { + it.isNeverCalled() + } + } + } + } + + @Test + fun `when ringing call PushData is received, the incoming call will be handled`() = runTest { + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) + } + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest { + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION))))) + } + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isNeverCalled() + onNotifiableEventsReceived.assertions().isCalledOnce() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest { + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) + } + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest { + val aRedaction = ResolvedPushEvent.Redaction( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + redactedEventId = AN_EVENT_ID_2, + reason = null + ) + val onRedactedEventReceived = lambdaRecorder, Unit> { } + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + onRedactedEventReceived = onRedactedEventReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.success(aRedaction))) + } + + advanceTimeBy(300.milliseconds) + + onRedactedEventReceived.assertions().isCalledOnce() + .with(value(listOf(aRedaction))) + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { + val aNotifiableFallbackEvent = aFallbackNotifiableEvent() + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + var receivedFallbackEvent = false + val onPushReceivedResult = + lambdaRecorder { _, _, _, _, isResolved, _, comment -> + receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" + } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + + val processor = createDefaultNotificationResultProcessor( + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent)))) + } + + advanceTimeBy(300.milliseconds) + + onNotifiableEventsReceived.assertions().isCalledOnce() + + assertThat(receivedFallbackEvent).isTrue() + } + + private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) { + processor.start() + + runCurrent() + + block(processor) + + runCurrent() + + processor.stop() + } + + private fun TestScope.createDefaultNotificationResultProcessor( + systemClock: FakeSystemClock = FakeSystemClock(), + pushHistoryService: FakePushHistoryService = FakePushHistoryService(), + mutableBatteryOptimizationStore: FakeMutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + fallbackNotificationFactory: FallbackNotificationFactory = FallbackNotificationFactory(systemClock), + userPushStoreFactory: FakeUserPushStoreFactory = FakeUserPushStoreFactory(), + onRedactedEventReceived: (List) -> Unit = {}, + onNotifiableEventsReceived: (List) -> Unit = {}, + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + syncOnNotifiableEvent: SyncOnNotifiableEvent = {}, + elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), + notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), + coroutineScope: CoroutineScope = backgroundScope, + ) = DefaultNotificationResultProcessor( + pushHistoryService = pushHistoryService, + batteryOptimizationStore = mutableBatteryOptimizationStore, + fallbackNotificationFactory = fallbackNotificationFactory, + userPushStoreFactory = userPushStoreFactory, + onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived), + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived), + featureFlagService = featureFlagService, + syncOnNotifiableEvent = syncOnNotifiableEvent, + elementCallEntryPoint = elementCallEntryPoint, + notificationChannels = notificationChannels, + coroutineScope = coroutineScope, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt index 17ba7448ec..a83f582a58 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -9,18 +9,18 @@ package io.element.android.libraries.push.impl.notifications import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.tests.testutils.lambda.lambdaError class FakeNotifiableEventResolver( - private val resolveEventsResult: (SessionId, List) -> Result>> = + private val resolveEventsResult: (SessionId, List) -> Result>> = { _, _ -> lambdaError() } ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, - notificationEventRequests: List - ): Result>> { + notificationEventRequests: List + ): Result>> { return resolveEventsResult(sessionId, notificationEventRequests) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt new file mode 100644 index 0000000000..da73cd4560 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationResultProcessor( + private val emit: (Map>) -> Unit = { lambdaError() }, + private val start: () -> Unit = { lambdaError() }, + private val stop: () -> Unit = { lambdaError() }, +) : NotificationResultProcessor { + override suspend fun emit(results: Map>) { + return emit.invoke(results) + } + + override fun start() { + start.invoke() + } + + override fun stop() { + stop.invoke() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/PushRequestFixture.kt similarity index 58% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/PushRequestFixture.kt index c450287fbd..4d2e475fa1 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/PushRequestFixture.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -14,16 +13,22 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.push.PushRequestStatus -fun aNotificationEventRequest( +fun aPushRequest( sessionId: SessionId = A_SESSION_ID, roomId: RoomId = A_ROOM_ID, eventId: EventId = AN_EVENT_ID, - providerInfo: String = "providerInfo", -) = NotificationEventRequest( - sessionId = sessionId, - roomId = roomId, - eventId = eventId, + providerInfo: String = "firebase", + status: PushRequestStatus = PushRequestStatus.PENDING, + retries: Int = 0, +) = PushRequest( + pushDate = System.currentTimeMillis(), providerInfo = providerInfo, + eventId = eventId.value, + roomId = roomId.value, + sessionId = sessionId.value, + status = status.value, + retries = retries.toLong(), ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 5d3af86eea..733b2b64be 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -11,65 +11,38 @@ package io.element.android.libraries.push.impl.push import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.api.CallType -import io.element.android.features.call.test.FakeElementCallEntryPoint -import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.core.meta.BuildMeta -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.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.exception.NotificationResolverException -import io.element.android.libraries.matrix.api.notification.RtcNotificationType -import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SECRET -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.aBuildMeta -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.history.FakePushHistoryService import io.element.android.libraries.push.impl.history.PushHistoryService -import io.element.android.libraries.push.impl.notifications.DefaultNotificationResolverQueue -import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver -import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory -import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels -import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler -import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter import io.element.android.libraries.pushproviders.api.PushData -import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.systemclock.FakeSystemClock -import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Test -import java.time.Instant import kotlin.time.Duration.Companion.milliseconds private const val A_PUSHER_INFO = "info" @@ -96,84 +69,36 @@ class DefaultPushHandlerTest { } @Test - fun `when classical PushData is received, the notification drawer is informed`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + fun `when classical PushData is received, the work is scheduled`() = runTest { val incrementPushCounterResult = lambdaRecorder {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) val aPushData = PushData( eventId = AN_EVENT_ID, roomId = A_ROOM_ID, unread = 0, clientSecret = A_SECRET, ) + + val enqueuePushRequestResult = lambdaRecorder> { Result.success(Unit) } + val pushHistoryService = FakePushHistoryService( + enqueuePushRequest = enqueuePushRequestResult, + ) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) + val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), incrementPushCounterResult = incrementPushCounterResult, + workManagerScheduler = workManagerScheduler, pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) advanceTimeBy(300.milliseconds) - incrementPushCounterResult.assertions() + submitWorkLambda.assertions() .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), any()) - onNotifiableEventsReceived.assertions() - .isCalledOnce() - .with(value(listOf(aNotifiableMessageEvent))) - onPushReceivedResult.assertions() - .isCalledOnce() - } - - @Test - fun `when classical PushData is received and the workmanager flag is enabled, the work is scheduled`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) - } - val incrementPushCounterResult = lambdaRecorder {} - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - - val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to true)) - val submitWorkLambda = lambdaRecorder {} - val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) - - val defaultPushHandler = createDefaultPushHandler( - notifiableEventsResult = notifiableEventResult, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - featureFlagService = featureFlagService, - workManagerScheduler = workManagerScheduler, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - submitWorkLambda.assertions().isCalledOnce() incrementPushCounterResult.assertions() .isCalledOnce() @@ -182,13 +107,6 @@ class DefaultPushHandlerTest { @Test fun `when classical PushData is received, but notifications are disabled, nothing happen`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -197,12 +115,15 @@ class DefaultPushHandlerTest { clientSecret = A_SECRET, ) val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val enqueuePushRequestResult = lambdaRecorder> { Result.success(Unit) } val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, + enqueuePushRequest = enqueuePushRequestResult, ) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) + val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), @@ -211,31 +132,24 @@ class DefaultPushHandlerTest { }, incrementPushCounterResult = incrementPushCounterResult, pushHistoryService = pushHistoryService, + workManagerScheduler = workManagerScheduler, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) advanceTimeBy(300.milliseconds) + submitWorkLambda.assertions() + .isNeverCalled() + enqueuePushRequestResult.assertions() + .isNeverCalled() incrementPushCounterResult.assertions() .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - onNotifiableEventsReceived.assertions() - .isNeverCalled() onPushReceivedResult.assertions() - .isCalledOnce() + .isNeverCalled() } @Test - fun `when PushData is received, but client secret is not known, nothing happen`() = - runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + fun `when PushData is received, but client secret is not known, nothing happen`() = runTest { val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -247,477 +161,85 @@ class DefaultPushHandlerTest { val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, ) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { null } ), incrementPushCounterResult = incrementPushCounterResult, pushHistoryService = pushHistoryService, + workManagerScheduler = workManagerScheduler, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + submitWorkLambda.assertions() + .isNeverCalled() incrementPushCounterResult.assertions() .isCalledOnce() - notifiableEventResult.assertions() - .isNeverCalled() - onNotifiableEventsReceived.assertions() - .isNeverCalled() onPushReceivedResult.assertions() .isCalledOnce() } @Test - fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() { - `test notification resolver failure`( - notificationResolveResult = { _ -> - Result.failure(NotificationResolverException.UnknownError("Unable to restore session")) - }, - shouldSetOptimizationBatteryBanner = false, + fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = runTest { + val aPushData = PushData( + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, ) - } - - @Test - fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() { - `test notification resolver failure`( - notificationResolveResult = { requests: List -> - Result.success( - requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) } - ) - }, - shouldSetOptimizationBatteryBanner = true, + val diagnosticPushHandler = DiagnosticPushHandler() + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, ) - } - - private fun `test notification resolver failure`( - notificationResolveResult: (List) -> Result>>, - shouldSetOptimizationBatteryBanner: Boolean, - ) { - runTest { - val notifiableEventResult = - lambdaRecorder, Result>>> { _, requests -> - notificationResolveResult(requests) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val showBatteryOptimizationBannerResult = lambdaRecorder {} - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - buildMeta = aBuildMeta( - // Also test `lowPrivacyLoggingEnabled = false` here - lowPrivacyLoggingEnabled = false - ), - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( - showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult, - ), - pushHistoryService = pushHistoryService, - ) + val defaultPushHandler = createDefaultPushHandler( + diagnosticPushHandler = diagnosticPushHandler, + incrementPushCounterResult = { }, + pushHistoryService = pushHistoryService, + ) + diagnosticPushHandler.state.test { defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), any()) - onPushReceivedResult.assertions() - .isCalledOnce() - .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any()) - showBatteryOptimizationBannerResult.assertions().let { - if (shouldSetOptimizationBatteryBanner) { - it.isCalledOnce() - } else { - it.isNeverCalled() - } - } + awaitItem() } - } - - @Test - fun `when ringing call PushData is received, the incoming call will be handled`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val handleIncomingCallLambda = lambdaRecorder< - CallType.RoomCall, - EventId, - UserId, - String?, - String?, - String?, - String, - String?, - Unit, - > { _, _, _, _, _, _, _, _ -> } - val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - elementCallEntryPoint = elementCallEntryPoint, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success( - mapOf( - request to Result.success( - ResolvedPushEvent.Event( - aNotifiableCallEvent(rtcNotificationType = RtcNotificationType.RING, timestamp = Instant.now().toEpochMilli()) - ) - ) - ) - ) - }, - incrementPushCounterResult = {}, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - onNotifiableEventsReceived = onNotifiableEventsReceived, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - handleIncomingCallLambda.assertions().isCalledOnce() - onNotifiableEventsReceived.assertions().isNeverCalled() - onPushReceivedResult.assertions().isCalledOnce() - } - - @Test - fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val handleIncomingCallLambda = lambdaRecorder< - CallType.RoomCall, - EventId, - UserId, - String?, - String?, - String?, - String, - String?, - Unit, - > { _, _, _, _, _, _, _, _ -> } - val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - elementCallEntryPoint = elementCallEntryPoint, - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION))))) - }, - incrementPushCounterResult = {}, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - handleIncomingCallLambda.assertions().isNeverCalled() - onNotifiableEventsReceived.assertions().isCalledOnce() - onPushReceivedResult.assertions().isCalledOnce() - } - - @Test - fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val handleIncomingCallLambda = lambdaRecorder< - CallType.RoomCall, - EventId, - UserId, - String?, - String?, - String?, - String, - String?, - Unit, - > { _, _, _, _, _, _, _, _ -> } - val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - elementCallEntryPoint = elementCallEntryPoint, - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) - }, - incrementPushCounterResult = {}, - userPushStore = FakeUserPushStore().apply { - setNotificationEnabledForDevice(false) - }, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - handleIncomingCallLambda.assertions().isCalledOnce() - onNotifiableEventsReceived.assertions().isNeverCalled() - onPushReceivedResult.assertions().isCalledOnce() - } - - @Test - fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val aRedaction = ResolvedPushEvent.Redaction( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - redactedEventId = AN_EVENT_ID_2, - reason = null - ) - val onRedactedEventReceived = lambdaRecorder, Unit> { } - val incrementPushCounterResult = lambdaRecorder {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - onRedactedEventsReceived = onRedactedEventReceived, - incrementPushCounterResult = incrementPushCounterResult, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(aRedaction))) - }, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledOnce() - onRedactedEventReceived.assertions().isCalledOnce() - .with(value(listOf(aRedaction))) onPushReceivedResult.assertions() .isCalledOnce() } - @Test - fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = - runTest { - val aPushData = PushData( - eventId = DefaultTestPush.TEST_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val diagnosticPushHandler = DiagnosticPushHandler() - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - diagnosticPushHandler = diagnosticPushHandler, - incrementPushCounterResult = { }, - pushHistoryService = pushHistoryService, - ) - diagnosticPushHandler.state.test { - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - awaitItem() - } - onPushReceivedResult.assertions() - .isCalledOnce() - } - - @Test - fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val anotherPushData = PushData( - eventId = AN_EVENT_ID_2, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - defaultPushHandler.handle(anotherPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledExactly(2) - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), matching> { requests -> - requests.size == 2 && requests.first().eventId == AN_EVENT_ID && requests.last().eventId == AN_EVENT_ID_2 - }) - onNotifiableEventsReceived.assertions() - .isCalledOnce() - onPushReceivedResult.assertions() - .isCalledExactly(2) - } - - @Test - fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { - val aNotifiableFallbackEvent = aFallbackNotifiableEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - var receivedFallbackEvent = false - val onPushReceivedResult = - lambdaRecorder { _, _, _, _, isResolved, _, comment -> - receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" - } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - onNotifiableEventsReceived.assertions().isCalledOnce() - - assertThat(receivedFallbackEvent).isTrue() - } - - private fun TestScope.createDefaultPushHandler( - onNotifiableEventsReceived: (List) -> Unit = { lambdaError() }, - onRedactedEventsReceived: (List) -> Unit = { lambdaError() }, - notifiableEventsResult: (SessionId, List) -> Result>> = - { _, _ -> lambdaError() }, + private fun createDefaultPushHandler( incrementPushCounterResult: () -> Unit = { lambdaError() }, - mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), - userPushStore: UserPushStore = FakeUserPushStore(), + userPushStore: FakeUserPushStore = FakeUserPushStore(), pushClientSecret: PushClientSecret = FakePushClientSecret(), buildMeta: BuildMeta = aBuildMeta(), diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), - elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), - notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), pushHistoryService: PushHistoryService = FakePushHistoryService(), - syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, - featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to false)), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + systemClock: FakeSystemClock = FakeSystemClock(), + buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), + resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor( + emit = { Result.success(Unit) }, + start = {}, + stop = {}, + ), ): DefaultPushHandler { return DefaultPushHandler( - onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived), - onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived), incrementPushDataStore = object : IncrementPushDataStore { override suspend fun incrementPushCounter() { incrementPushCounterResult() } }, - mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, pushClientSecret = pushClientSecret, buildMeta = buildMeta, diagnosticPushHandler = diagnosticPushHandler, - elementCallEntryPoint = elementCallEntryPoint, - notificationChannels = notificationChannels, pushHistoryService = pushHistoryService, // We don't use a fake here so we can perform tests that are a bit more end to end - resolverQueue = DefaultNotificationResolverQueue( - notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), - appCoroutineScope = backgroundScope, - workManagerScheduler = workManagerScheduler, - featureFlagService = featureFlagService, - workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), - ), - appCoroutineScope = backgroundScope, - fallbackNotificationFactory = FallbackNotificationFactory( - clock = FakeSystemClock(), - ), - syncOnNotifiableEvent = syncOnNotifiableEvent, - featureFlagService = featureFlagService, analyticsService = analyticsService, + systemClock = systemClock, + workManagerScheduler = workManagerScheduler, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + resultProcessor = resultProcessor, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt index 6c88e7bf12..0032e95029 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt @@ -20,8 +20,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.sync.FakeSyncService -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest +import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -53,7 +52,7 @@ class SyncOnNotifiableEventTest { givenGetRoomResult(A_ROOM_ID, room) } - private val notificationRequest = aNotificationEventRequest() + private val notificationRequest = aPushRequest() @Test fun `when feature flag is disabled, nothing happens`() = runTest { diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt deleted file mode 100644 index 99451027a8..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.work.Data -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor -import androidx.work.workDataOf -import com.google.common.truth.Truth.assertThat -import com.google.common.util.concurrent.ListenableFuture -import io.element.android.features.networkmonitor.api.NetworkStatus -import io.element.android.features.networkmonitor.test.FakeNetworkMonitor -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent -import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver -import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent -import io.element.android.libraries.push.test.notifications.FakeNotificationResolverQueue -import io.element.android.libraries.workmanager.api.WorkManagerRequest -import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory -import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import io.element.android.tests.testutils.lambda.lambdaRecorder -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import java.util.UUID -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.seconds - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -class FetchNotificationWorkerTest { - @Test - fun `test - success`() = runTest { - var synced = false - val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { synced = true } - - val queue = FakeNotificationResolverQueue( - processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) } - ) - val worker = createWorker( - input = """ - [ - { - "session_id": "@alice:matrix.org", - "room_id": "!roomid:matrix.org", - "event_id": "$1436ebk:matrix.org", - "provider_info": "some_info" - } - ] - """.trimIndent(), - queue = queue, - syncOnNotifiableEvent = syncOnNotifiableEventLambda, - ) - - val result = worker.doWork() - - // The process finished successfully - assertThat(result).isEqualTo(ListenableWorker.Result.success()) - - // A result was emitted - assertThat(queue.results.replayCache).isNotEmpty() - - // An opportunistic sync was triggered - assertThat(synced).isTrue() - } - - @Test - fun `test - invalid input fails the work`() = runTest { - val worker = createWorker( - input = """ - [ - { - "session_id": "!alice:matrix.org", - "room_id": "!roomid:matrix.org", - "event_id": "$1436ebk:matrix.org", - "provider_info": "some_info" - } - ] - """.trimIndent(), - ) - - val result = worker.doWork() - - // The process failed - assertThat(result).isEqualTo(ListenableWorker.Result.failure()) - } - - @Test - fun `test - no network connectivity fails the work`() = runTest { - val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) - val worker = createWorker( - input = """ - [ - { - "session_id": "@alice:matrix.org", - "room_id": "!roomid:matrix.org", - "event_id": "$1436ebk:matrix.org", - "provider_info": "some_info" - } - ] - """.trimIndent(), - networkMonitor = networkMonitor, - ) - - val result = worker.doWork() - - advanceTimeBy(10.seconds) - - // The process failed due to a timeout in getting the network connectivity, a retry is scheduled - assertThat(result).isEqualTo(ListenableWorker.Result.retry()) - } - - @Test - fun `test - failing to resolve events re-schedules the work`() = runTest { - val submitWorkerLambda = lambdaRecorder {} - val scheduler = FakeWorkManagerScheduler(submitLambda = submitWorkerLambda) - - val resolver = FakeNotifiableEventResolver( - resolveEventsResult = { _, _ -> Result.failure(Exception("Failed to resolve events")) } - ) - - val worker = createWorker( - input = """ - [ - { - "session_id": "@alice:matrix.org", - "room_id": "!roomid:matrix.org", - "event_id": "$1436ebk:matrix.org", - "provider_info": "some_info" - } - ] - """.trimIndent(), - eventResolver = resolver, - workManagerScheduler = scheduler, - ) - - val result = worker.doWork() - - // The process was considered successful, but a retry was scheduled due to the failure to resolve events - assertThat(result).isEqualTo(ListenableWorker.Result.success()) - submitWorkerLambda.assertions().isCalledOnce() - } - - private fun TestScope.createWorker( - input: String, - networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), - eventResolver: FakeNotifiableEventResolver = FakeNotifiableEventResolver(resolveEventsResult = { _, _ -> Result.success(emptyMap()) }), - queue: NotificationResolverQueue = FakeNotificationResolverQueue( - processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) } - ), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, - analyticsService: FakeAnalyticsService = FakeAnalyticsService(), - ) = FetchNotificationsWorker( - params = createWorkerParams(workDataOf("requests" to input)), - context = InstrumentationRegistry.getInstrumentation().context, - networkMonitor = networkMonitor, - eventResolver = eventResolver, - queue = queue, - workManagerScheduler = workManagerScheduler, - syncOnNotifiableEvent = syncOnNotifiableEvent, - workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), - analyticsService = analyticsService, - ) - - private fun TestScope.createWorkerParams( - inputData: Data = Data.EMPTY, - ): WorkerParameters = WorkerParameters( - UUID.randomUUID(), - inputData, - emptySet(), - WorkerParameters.RuntimeExtras(), - 0, - 0, - Executors.newSingleThreadExecutor(), - backgroundScope.coroutineContext, - WorkManagerTaskExecutor(Executors.newSingleThreadExecutor()), - MetroWorkerFactory(emptyMap()), - { context, id, data -> FakeListenableFuture() }, - { context, id, foregroundInfo -> FakeListenableFuture() }, - ) -} - -class FakeListenableFuture : ListenableFuture { - override fun addListener(listener: Runnable, executor: Executor) = Unit - override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true - override fun get(): T? = null - override fun get(timeout: Long, unit: TimeUnit?): T? = null - override fun isCancelled(): Boolean = false - override fun isDone(): Boolean = false -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt new file mode 100644 index 0000000000..b39c3d3f05 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor +import androidx.work.workDataOf +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.ListenableFuture +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.FakePushHistoryService +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor +import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class FetchPendingNotificationWorkerTest { + @Test + fun `test - success`() = runTest { + var synced = false + val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { synced = true } + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + + val getPendingResultsLambda = lambdaRecorder>> { _, _ -> Result.success(listOf(aPushRequest())) } + val replacePushRequestsLambda = lambdaRecorder, Result> { Result.success(Unit) } + val removeOldPushRequestsLambda = lambdaRecorder> { Result.success(Unit) } + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = getPendingResultsLambda, + replacePushRequests = replacePushRequestsLambda, + removeOldPushRequests = removeOldPushRequestsLambda, + ) + + val worker = createWorker( + input = "@alice:matrix.org", + pushHistoryService = pushHistoryService, + resultProcessor = processor, + syncOnNotifiableEvent = syncOnNotifiableEventLambda, + ) + + val result = worker.doWork() + + // The expected data is fetched and replaced from the service + getPendingResultsLambda.assertions().isCalledOnce() + replacePushRequestsLambda.assertions().isCalledOnce() + removeOldPushRequestsLambda.assertions().isCalledOnce() + + // The process finished successfully + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + + // A result was emitted + emitResultLambda.assertions().isCalledOnce() + + // An opportunistic sync was triggered + assertThat(synced).isTrue() + } + + @Test + fun `test - invalid input fails the work`() = runTest { + val worker = createWorker(input = "!alice:matrix.org") + + val result = worker.doWork() + + // The process failed + assertThat(result).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `test - no network connectivity fails the work`() = runTest { + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(aPushRequest())) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + val worker = createWorker( + input = "@alice:matrix.org", + networkMonitor = networkMonitor, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + advanceTimeBy(10.seconds) + + // The process failed due to a timeout in getting the network connectivity, a retry is scheduled + assertThat(result).isEqualTo(ListenableWorker.Result.retry()) + } + + @Test + fun `test - failing to setup retries the work`() = runTest { + val submitWorkerLambda = lambdaRecorder {} + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(aPushRequest())) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> Result.failure(Exception("Failed to resolve events")) } + ) + + val worker = createWorker( + input = "@alice:matrix.org", + eventResolver = resolver, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.retry()) + // Never called since we don't need to re-submit + submitWorkerLambda.assertions().isNeverCalled() + } + + @Test + fun `test - failing to resolve events with recoverable error retries the work`() { + val pushRequest = aPushRequest() + runTest { + val submitWorkerLambda = lambdaRecorder {} + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(pushRequest)) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> + Result.success(mapOf(pushRequest to Result.failure(ClientException.Generic("error sending request for url", null)))) + } + ) + + val worker = createWorker( + input = "@alice:matrix.org", + eventResolver = resolver, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.retry()) + + // Never called since we don't need to re-submit + submitWorkerLambda.assertions().isNeverCalled() + + // We do save the updated events to the push DB + emitResultLambda.assertions().isCalledOnce() + } + } + + @Test + fun `test - failing to resolve events with unrecoverable error saves the new state and ends as success`() { + val pushRequest = aPushRequest() + runTest { + val submitWorkerLambda = lambdaRecorder {} + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(pushRequest)) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> + Result.success(mapOf(pushRequest to Result.failure(IllegalStateException("Unrecoverable")))) + } + ) + + val worker = createWorker( + input = "@alice:matrix.org", + eventResolver = resolver, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + + // Never called since we don't need to re-submit + submitWorkerLambda.assertions().isNeverCalled() + + // We do save the updated events to the push DB + emitResultLambda.assertions().isCalledOnce() + } + } + + private fun TestScope.createWorker( + input: String, + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), + eventResolver: FakeNotifiableEventResolver = FakeNotifiableEventResolver(resolveEventsResult = { _, _ -> Result.success(emptyMap()) }), + syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + pushHistoryService: FakePushHistoryService = FakePushHistoryService(), + resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(), + systemClock: FakeSystemClock = FakeSystemClock(), + ) = FetchPendingNotificationsWorker( + params = createWorkerParams(workDataOf("session_id" to input)), + context = InstrumentationRegistry.getInstrumentation().context, + networkMonitor = networkMonitor, + eventResolver = eventResolver, + syncOnNotifiableEvent = syncOnNotifiableEvent, + analyticsService = analyticsService, + pushHistoryService = pushHistoryService, + resultProcessor = resultProcessor, + systemClock = systemClock, + ) + + private fun TestScope.createWorkerParams( + inputData: Data = Data.EMPTY, + ): WorkerParameters = WorkerParameters( + UUID.randomUUID(), + inputData, + emptySet(), + WorkerParameters.RuntimeExtras(), + 0, + 0, + Executors.newSingleThreadExecutor(), + backgroundScope.coroutineContext, + WorkManagerTaskExecutor(Executors.newSingleThreadExecutor()), + MetroWorkerFactory(emptyMap()), + { context, id, data -> FakeListenableFuture() }, + { context, id, foregroundInfo -> FakeListenableFuture() }, + ) +} + +class FakeListenableFuture : ListenableFuture { + override fun addListener(listener: Runnable, executor: Executor) = Unit + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true + override fun get(): T? = null + override fun get(timeout: Long, unit: TimeUnit?): T? = null + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = false +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt deleted file mode 100644 index 1f8d646e2b..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import androidx.work.OneTimeWorkRequest -import androidx.work.hasKeyWithValueOfType -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest -import io.element.android.libraries.workmanager.api.WorkManagerRequestType -import io.element.android.libraries.workmanager.api.workManagerTag -import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.collections.first - -class SyncNotificationWorkManagerRequestTest { - @Test - fun `build - success API 33`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()), - sdkVersion = 33, - ) - - val result = request.build() - assertThat(result.isSuccess).isTrue() - result.getOrNull()!!.first().run { - assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) - assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() - // True in API 33+ - assertThat(workSpec.expedited).isTrue() - assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) - } - } - - @Test - fun `build - success API 32 and lower`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()), - sdkVersion = 32, - ) - - val result = request.build() - assertThat(result.isSuccess).isTrue() - result.getOrNull()!!.first().run { - assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) - assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() - // False before API 33 - assertThat(workSpec.expedited).isFalse() - assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) - } - } - - @Test - fun `build - empty list of requests fails`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = emptyList() - ) - - val result = request.build() - assertThat(result.isFailure).isTrue() - } - - @Test - fun `build - invalid serialization`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()), - workerDataConverter = SyncNotificationsWorkerDataConverter({ error("error during serialization") }) - ) - val result = request.build() - assertThat(result.isFailure).isTrue() - } -} - -private fun createSyncNotificationWorkManagerRequest( - sessionId: SessionId, - notificationEventRequests: List, - workerDataConverter: SyncNotificationsWorkerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), - sdkVersion: Int = 33, -) = SyncNotificationWorkManagerRequest( - sessionId = sessionId, - notificationEventRequests = notificationEventRequests, - workerDataConverter = workerDataConverter, - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), -) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt new file mode 100644 index 0000000000..c7d54973e3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import androidx.work.OneTimeWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SyncPendingNotificationsRequestBuilderTest { + @Test + fun `build - success API 33`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 33, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + results.getOrNull()!!.first().let { result -> + assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) + result.request.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() + // True in API 33+ + assertThat(workSpec.expedited).isTrue() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + } + + @Test + fun `build - success API 32 and lower`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 32, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + + results.getOrNull()!!.first().let { result -> + assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) + result.request.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() + // False before API 33 + assertThat(workSpec.expedited).isFalse() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + } +} + +private fun createSyncPendingNotificationsRequestBuilder( + sessionId: SessionId, + sdkVersion: Int = 33, +) = SyncPendingNotificationsRequestBuilder( + sessionId = sessionId, + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt deleted file mode 100644 index 85b55e0d62..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_ID_3 -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_SESSION_ID_2 -import io.element.android.libraries.push.api.push.NotificationEventRequest -import org.junit.Test - -class WorkerDataConverterTest { - @Test - fun `ensure identity when serializing - deserializing an empty list`() { - testIdentity(emptyList()) - } - - @Test - fun `ensure identity when serializing - deserializing a list`() { - testIdentity( - listOf( - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, - providerInfo = "info1", - ), - NotificationEventRequest( - sessionId = A_SESSION_ID_2, - roomId = A_ROOM_ID_2, - eventId = AN_EVENT_ID_2, - providerInfo = "info2", - ), - ) - ) - } - - @Test - fun `serializing lots of data leads to several work data generated - one room - 100 events should be split in 5 chunks`() { - val data = List(100) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info$it", - ) - } - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data) - assertThat(serialized.getOrNull()?.size).isGreaterThan(1) - assertThat(serialized.getOrNull()?.size).isEqualTo(100 / SyncNotificationsWorkerDataConverter.CHUNK_SIZE) - // All the items are present - val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } - assertThat(deserialized).containsExactlyElementsIn(data) - } - - @Test - fun `serializing lots of data leads to several work data generated - one room - 101 events should be split in 6 chunks`() { - val data = List(101) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info$it", - ) - } - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data) - assertThat(serialized.getOrNull()?.size).isGreaterThan(1) - assertThat(serialized.getOrNull()?.size).isEqualTo(100 / SyncNotificationsWorkerDataConverter.CHUNK_SIZE + 1) - // All the items are present - val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } - assertThat(deserialized).containsExactlyElementsIn(data) - } - - @Test - fun `serializing lots of data leads to several work data generated - 3 rooms - 25 events should be split in 2 chunks and room not mixed`() { - val data1 = List(15) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info".repeat(100) + it, - ) - } - val data2 = List(3) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID_2, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info".repeat(100) + it, - ) - } - val data3 = List(7) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID_3, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info".repeat(100) + it, - ) - } - val data = (data1 + data2 + data3).shuffled() - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data) - assertThat(serialized.getOrNull()?.size).isEqualTo(2) - // All the items are present - val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } - assertThat(deserialized).containsExactlyElementsIn(data) - // Rooms are not mixed between the chunks - val setsOfRooms = serialized.getOrNull()!! - .map { workData -> sut.deserialize(workData)!! } - .map { - it.map { request -> request.roomId }.toSet() - } - // Ensure that all sets are distinct - assertThat(setsOfRooms.size).isEqualTo(2) - // 3 roomId are present - assertThat(setsOfRooms.flatten().toSet()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3) - // No intersection between sets - assertThat(setsOfRooms[0].intersect(setsOfRooms[1])).isEmpty() - } - - private fun testIdentity(data: List) { - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data).getOrThrow() - val result = sut.deserialize(serialized.first()) - assertThat(result).isEqualTo(data) - } -} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt deleted file mode 100644 index d4279ab028..0000000000 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.test.notifications - -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent -import kotlinx.coroutines.flow.MutableSharedFlow - -class FakeNotificationResolverQueue( - private val processingLambda: suspend (NotificationEventRequest) -> Result, -) : NotificationResolverQueue { - override val results = MutableSharedFlow, Map>>>(replay = 1) - - override suspend fun enqueue(request: NotificationEventRequest) { - results.emit(listOf(request) to mapOf(request to processingLambda(request))) - } -} diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm index 4577105e3d..238e4514f0 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm @@ -1,4 +1,4 @@ --- This file is not striclty necessary, since the first +-- This file is not strictly necessary, since the first -- version of the DB is 1, so we will never migrate from 0 CREATE TABLE SessionData ( diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt deleted file mode 100644 index af49c3dc87..0000000000 --- a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.workmanager.api - -import androidx.work.WorkRequest - -interface WorkManagerRequest { - fun build(): Result> -} diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt new file mode 100644 index 0000000000..a82ddf165d --- /dev/null +++ b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.workmanager.api + +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkRequest + +/** + * A base class that can be customized to [build] work requests to schedule in `WorkManager`. + */ +interface WorkManagerRequestBuilder { + /** + * Builds a work request wrapper using the provided data. + */ + suspend fun build(): Result> +} + +/** + * A wrapper that allows us to avoid using Android APIs directly when scheduling workers. + */ +data class WorkManagerRequestWrapper( + val request: WorkRequest, + val type: WorkManagerWorkerType = WorkManagerWorkerType.Default, +) + +/** + * The type of worker to use when scheduling the task. + */ +sealed interface WorkManagerWorkerType { + /** + * This allows a single worker instance with the [name] id to run at the same time. Its [policy] can be customized. + */ + data class Unique(val name: String, val policy: ExistingWorkPolicy) : WorkManagerWorkerType + + /** + * The default worker type, with no custom rules. + */ + data object Default : WorkManagerWorkerType +} diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt index b538486d35..a616e01573 100644 --- a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt +++ b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt @@ -11,9 +11,21 @@ package io.element.android.libraries.workmanager.api import io.element.android.libraries.matrix.api.core.SessionId interface WorkManagerScheduler { - fun submit(workManagerRequest: WorkManagerRequest) + /** + * Submits a new work request built from [workManagerRequestBuilder] to run in `WorkManager`. + */ + suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) + + /** + * Checks if there are any pending requests scheduled for the provided [sessionId] and [requestType]. + */ fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean - fun cancel(sessionId: SessionId) + + /** + * Cancel pending work requests for the session [SessionId]. + * If [requestType] is provided, it will only cancel requests for that type, otherwise it will cancel all requests. + */ + fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType? = null) } fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): String { diff --git a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt index 4f3806db62..aa645c9a17 100644 --- a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt +++ b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.workmanager.impl +import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import dev.zacsweers.metro.AppScope @@ -16,9 +17,10 @@ import dev.zacsweers.metro.SingleIn import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType import io.element.android.libraries.workmanager.api.workManagerTag import timber.log.Timber @@ -41,13 +43,22 @@ class DefaultWorkManagerScheduler( }) } - override fun submit(workManagerRequest: WorkManagerRequest) { - workManagerRequest.build().fold( - onSuccess = { workRequests -> - workManager.enqueue(workRequests) + override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) { + workManagerRequestBuilder.build().fold( + onSuccess = { wrappers -> + for (wrapper in wrappers) { + when (wrapper.type) { + WorkManagerWorkerType.Default -> workManager.enqueue(wrapper.request) + is WorkManagerWorkerType.Unique -> { + val type = wrapper.type as WorkManagerWorkerType.Unique + val requests = wrapper.request as OneTimeWorkRequest + workManager.enqueueUniqueWork(type.name, type.policy, requests) + } + } + } }, onFailure = { - Timber.e(it, "Failed to build WorkManager request $workManagerRequest") + Timber.e(it, "Failed to build WorkManager request $workManagerRequestBuilder") } ) } @@ -64,10 +75,15 @@ class DefaultWorkManagerScheduler( } } - override fun cancel(sessionId: SessionId) { + override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) { Timber.d("Cancelling work for sessionId: $sessionId") - for (requestType in WorkManagerRequestType.entries) { + + if (requestType != null) { workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType)) + } else { + for (requestType in WorkManagerRequestType.entries) { + workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType)) + } } } } diff --git a/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt b/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt index b4f964ed12..761a82e0f4 100644 --- a/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt +++ b/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt @@ -7,12 +7,17 @@ package io.element.android.libraries.workmanager.impl +import android.content.Context +import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkRequest +import androidx.work.Worker +import androidx.work.WorkerParameters import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.api.workManagerTag import io.mockk.every import io.mockk.mockk @@ -55,7 +60,7 @@ class DefaultWorkManagerSchedulerTest { sessionObserver = FakeSessionObserver(), ) - scheduler.submit(FakeWorkManagerRequest()) + scheduler.submit(FakeWorkManagerRequestBuilder()) verify { workManager.enqueue(any>()) } } @@ -69,7 +74,7 @@ class DefaultWorkManagerSchedulerTest { sessionObserver = FakeSessionObserver(), ) - scheduler.submit(FakeWorkManagerRequest(result = Result.failure(IllegalStateException("Test error")))) + scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.failure(IllegalStateException("Test error")))) verify(exactly = 0) { workManager.enqueue(any>()) } } @@ -88,7 +93,7 @@ class DefaultWorkManagerSchedulerTest { val mockSessionA = mockk { every { tags } returns setOf(tagToRemove) } - scheduler.submit(FakeWorkManagerRequest(result = Result.success(listOf(mockSessionA)))) + scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.success(listOf(WorkManagerRequestWrapper(mockSessionA))))) scheduler.cancel(sessionId) @@ -96,10 +101,16 @@ class DefaultWorkManagerSchedulerTest { } } -private class FakeWorkManagerRequest( - private val result: Result> = Result.success(listOf()), -) : WorkManagerRequest { - override fun build(): Result> { +private val workRequest = OneTimeWorkRequest.Builder(FakeWorker::class.java).build() + +private class FakeWorkManagerRequestBuilder( + private val result: Result> = Result.success(listOf(WorkManagerRequestWrapper(workRequest))), +) : WorkManagerRequestBuilder { + override suspend fun build(): Result> { return result } } + +internal class FakeWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result = Result.success() +} diff --git a/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt b/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt index f2caa8c743..aa39b48ed7 100644 --- a/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt +++ b/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt @@ -9,25 +9,25 @@ package io.element.android.libraries.workmanager.test import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaError class FakeWorkManagerScheduler( - private val submitLambda: (WorkManagerRequest) -> Unit = { lambdaError() }, + private val submitLambda: (WorkManagerRequestBuilder) -> Unit = { lambdaError() }, private val hasPendingWorkLambda: (SessionId, WorkManagerRequestType) -> Boolean = { _, _ -> false }, - private val cancelLambda: (SessionId) -> Unit = { lambdaError() }, + private val cancelLambda: (SessionId, WorkManagerRequestType?) -> Unit = { _, _ -> lambdaError() }, ) : WorkManagerScheduler { - override fun submit(workManagerRequest: WorkManagerRequest) { - submitLambda(workManagerRequest) + override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) { + submitLambda(workManagerRequestBuilder) } override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean { return hasPendingWorkLambda(sessionId, requestType) } - override fun cancel(sessionId: SessionId) { - cancelLambda(sessionId) + override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) { + cancelLambda(sessionId, requestType) } } From 5cfcffc45ef691f33b1c7bcc52e4935ab1e7f099 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 4 Mar 2026 14:37:16 +0000 Subject: [PATCH 19/20] Adjust the build-rust-sdk script to allow non-interactive use --- docs/_developer_onboarding.md | 4 +- tools/sdk/build-rust-sdk | 241 ++++++++++++++++++++++++++++++++++ tools/sdk/build_rust_sdk.sh | 101 -------------- 3 files changed, 243 insertions(+), 103 deletions(-) create mode 100755 tools/sdk/build-rust-sdk delete mode 100755 tools/sdk/build_rust_sdk.sh diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 7770c016fc..6035c875e1 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -144,8 +144,8 @@ Prerequisites: ``` You can then build the Rust SDK by running the script -[`tools/sdk/build_rust_sdk.sh`](../tools/sdk/build_rust_sdk.sh) and just answering -the questions. +[`tools/sdk/build-rust-sdk`](../tools/sdk/build-rust-sdk). Type +`./tools/sdk/build-rust-sdk --help` for help. This will prompt you for the path to the Rust SDK, then build it and `matrix-rust-components-kotlin`, eventually producing an aar file at diff --git a/tools/sdk/build-rust-sdk b/tools/sdk/build-rust-sdk new file mode 100755 index 0000000000..ea1b80c469 --- /dev/null +++ b/tools/sdk/build-rust-sdk @@ -0,0 +1,241 @@ +#!/usr/bin/env bash + +# Copyright (c) 2025-2026 Element Creations Ltd. +# Copyright 2024 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +# Please see LICENSE files in the repository root for full details. + +set -e +set -u + +# Usage: +# +# ./tools/sdk/build-rust-sdk --help +# + +# Notes: +# +# * matrix-rust-components-kotlin will be cloned into +# ../matrix-rust-components-kotlin and any changes in there will be +# overwritten (without prompting!) +# +# * If you opt to build a remote repo, it will be cloned into +# ../matrix-rust-sdk-<> + +## Defaults + +buildLocal=0 +rustSdkPath="../matrix-rust-sdk/" +rustSdkUrl="https://github.com/matrix-org/matrix-rust-sdk.git" +rustSdkBranch="main" +buildApp=1 + +default_arch="$(uname -m)-linux-android" +# On ARM MacOS, `uname -m` returns arm64, but the toolchain is called aarch64 +default_arch="${default_arch/arm64/aarch64}" + +target_arch="${default_arch}" + +sdkArg="" + +## Argument parsing + +TEMP=$(getopt -o 'rs:b:at:h' --long 'remote,sdk:,branch:,build-app,target-arch,help' -- "$@") + +if [ $? -ne 0 ]; then + echo 'Terminating...' >&2 + exit 1 +fi + +eval set -- "$TEMP" +unset TEMP + +while true; do + case "$1" in + 'r'|'--remote') + buildLocal=1 + shift + continue + ;; + 's'|'--sdk') + sdkArg="$2" + shift 2 + continue + ;; + 'b'|'--branch') + rustSdkBranch="$2" + shift 2 + continue + ;; + 'a'|'--build-app') + buildApp=0 + shift + continue + ;; + 't'|'--target-arch') + target_arch="$2" + shift 2 + continue + ;; + 'h'|'--help') + cat << END +SYNOPSIS + + $0 [-s|--sdk=PATH] [-a|--build-app] [-t|--target-arch=TARGET]" + + $0 --remote [-s|--sdk=URL] [-b|--branch=BRANCH] [-a|--build-app] [-t|--target-arch=TARGET]" + +ARGUMENTS + + -a --build-app + Build the Android app after the SDK is built. + + -b --branch + If --remote is supplied, the branch of the remote repo to build. + + -r --remote + Fetch the SDK code from a remote repo instead of building a local version. + + -s --sdk + The local path of the SDK to build, or the URL of the remote repo if --remote is provided. + + -t --target-arch + The architecture for which to build the app. Defaults to the architecture of this machine (${default_arch}). + +EXAMPLES + + $0 + Build the default local rust SDK (../matrix-rust-sdk) + + $0 --sdk=/home/andy/code/matrix-rust-sdk + Build the supplied local rust SDK + + $0 --remote + Build the default remote SDK + + $0 --remote --branch=featureA + Build the "featureA" branch of the remote SDK + + $0 --remote --sdk=https://github.com/andybalaam/matrix-rust-sdk.git + Build an alternative remote SDK + + $0 --build-app + Build the app after building the SDK + + $0 --build-app --target-arch=x86_64-linux-android + Build the app after building the SDK, for the x86_64 target architecture + +END + exit 0 + ;; + '--') + shift + break + ;; + *) + echo 'Unrecognised argument!' >&2 + exit 2 + ;; + esac +done + +if [ -n "${sdkArg}" ]; then + if [ "${buildLocal}" == "0" ]; then + rustSdkPath="${sdkArg}" + else + rustSdkUrl="${sdkArg}" + fi +fi + +#echo "buildLocal=${buildLocal}" +#echo "rustSdkPath=${rustSdkPath}" +#echo "rustSdkUrl=${rustSdkUrl}" +#echo "rustSdkBranch=${rustSdkBranch}" +#echo "buildApp=${buildApp}" + +## Find the date + +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + date=$(date +%Y%m%d%H%M%S) +else + date=$(gdate +%Y%m%d%H%M%S) +fi + +elementPwd=$(pwd) + +## Check the local build dir is valid, or clone the remote repo + +if [ "${buildLocal}" == "0" ]; then + if [ ! -d "${rustSdkPath}" ]; then + printf "\nFolder ${rustSdkPath} does not exist. Please clone the\n" >&2 + printf "matrix-rust-sdk repository into ../matrix-rust-sdk\n" >&2 + printf "or supply the --sdk argument.\n\n" >&2 + exit 3 + fi +else + printf "\n## Cloning the SDK repo...\n\n" + + cd .. + git clone "${rustSdkUrl}" matrix-rust-sdk-"$date" + cd matrix-rust-sdk-"$date" + git checkout "${rustSdkBranch}" + rustSdkPath=$(pwd) +fi + +cd "${elementPwd}" + +## Clone matrix-rust-components-kotlin if needed + +if [ ! -d "../matrix-rust-components-kotlin" ]; then + printf "\nFolder ../matrix-rust-components-kotlin does not exist." + printf "Cloning the repository into ../matrix-rust-components-kotlin.\n\n" + git clone \ + https://github.com/matrix-org/matrix-rust-components-kotlin.git \ + ../matrix-rust-components-kotlin +fi + +printf "\n## Resetting matrix-rust-components-kotlin to the latest main...\n\n" + +cd ../matrix-rust-components-kotlin +git reset --hard +git checkout main +git pull + +## Build the SDK + +printf "\n## Building the SDK for ${target_arch}...\n\n" + +./scripts/build.sh \ + -p "${rustSdkPath}" \ + -m sdk \ + -t "${target_arch}" \ + -o "${elementPwd}/libraries/rustsdk" + +cd "${elementPwd}" + +mv \ + ./libraries/rustsdk/sdk-android-debug.aar \ + ./libraries/rustsdk/matrix-rust-sdk.aar + +mkdir -p ./libraries/rustsdk/sdks + +cp \ + "./libraries/rustsdk/matrix-rust-sdk.aar" \ + "./libraries/rustsdk/sdks/matrix-rust-sdk-${date}.aar" + +## Build the app + +if [ "${buildApp}" == "0" ]; then + printf "\n## Building the application...\n\n" + ./gradlew assembleDebug +fi + +## Clean remote checkout of SDK repo + +if [ "${buildLocal}" != "0" ]; then + printf "\n## Cleaning up...\n\n" + rm -rf "../matrix-rust-sdk-$date" +fi + +printf "\n## Done!\n" diff --git a/tools/sdk/build_rust_sdk.sh b/tools/sdk/build_rust_sdk.sh deleted file mode 100755 index 0b74b0c4cb..0000000000 --- a/tools/sdk/build_rust_sdk.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2025 Element Creations Ltd. -# Copyright 2024 New Vector Ltd. -# -# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -# Please see LICENSE files in the repository root for full details. - -# Exit on error -set -e - -# Ask to build from local source or to clone the repository -read -p "Do you want to build the Rust SDK from local source (yes/no) default to yes? " buildLocal -buildLocal=${buildLocal:-yes} - -if [[ "$OSTYPE" == "linux-gnu"* ]]; then - date=$(date +%Y%m%d%H%M%S) -else - date=$(gdate +%Y%m%d%H%M%S) -fi - -elementPwd=$(pwd) - -# Ask for the Rust SDK local source path -# if folder rustSdk/ exists, use it as default -if [ "${buildLocal}" == "yes" ]; then - read -p "Please enter the path to the Rust SDK local source, default to ../matrix-rust-sdk" rustSdkPath - rustSdkPath=${rustSdkPath:-../matrix-rust-sdk/} - if [ ! -d "${rustSdkPath}" ]; then - printf "\nFolder ${rustSdkPath} does not exist. Please clone the matrix-rust-sdk repository in the folder ../matrix-rust-sdk.\n\n" - exit 0 - fi -else - read -p "Please enter the Rust SDK repository url, default to https://github.com/matrix-org/matrix-rust-sdk.git " rustSdkUrl - rustSdkUrl=${rustSdkUrl:-https://github.com/matrix-org/matrix-rust-sdk.git} - read -p "Please enter the Rust SDK branch, default to main " rustSdkBranch - rustSdkBranch=${rustSdkBranch:-main} - cd .. - git clone "${rustSdkUrl}" matrix-rust-sdk-"$date" - cd matrix-rust-sdk-"$date" - git checkout "${rustSdkBranch}" - rustSdkPath=$(pwd) - cd "${elementPwd}" -fi - - -cd "${rustSdkPath}" -git status - -read -p "Will build with this version of the Rust SDK ^. Is it correct (yes/no) default to yes? " sdkCorrect -sdkCorrect=${sdkCorrect:-yes} - -if [ "${sdkCorrect}" != "yes" ]; then - exit 0 -fi - -# Ask if the user wants to build the app after -read -p "Do you want to build the app after (yes/no) default to no? " buildApp -buildApp=${buildApp:-no} - -cd "${elementPwd}" - -default_arch="$(uname -m)-linux-android" -# On ARM MacOS, `uname -m` returns arm64, but the toolchain is called aarch64 -default_arch="${default_arch/arm64/aarch64}" - -read -p "Enter the architecture you want to build for (default '$default_arch'): " target_arch -target_arch="${target_arch:-${default_arch}}" - -# If folder ../matrix-rust-components-kotlin does not exist, clone the repo -if [ ! -d "../matrix-rust-components-kotlin" ]; then - printf "\nFolder ../matrix-rust-components-kotlin does not exist. Cloning the repository into ../matrix-rust-components-kotlin.\n\n" - git clone https://github.com/matrix-org/matrix-rust-components-kotlin.git ../matrix-rust-components-kotlin -fi - -printf "\nResetting matrix-rust-components-kotlin to the latest main branch...\n\n" -cd ../matrix-rust-components-kotlin -git reset --hard -git checkout main -git pull - -printf "\nBuilding the SDK for ${target_arch}...\n\n" -./scripts/build.sh -p "${rustSdkPath}" -m sdk -t "${target_arch}" -o "${elementPwd}/libraries/rustsdk" - -cd "${elementPwd}" -mv ./libraries/rustsdk/sdk-android-debug.aar ./libraries/rustsdk/matrix-rust-sdk.aar -mkdir -p ./libraries/rustsdk/sdks -cp ./libraries/rustsdk/matrix-rust-sdk.aar ./libraries/rustsdk/sdks/matrix-rust-sdk-"${date}".aar - - -if [ "${buildApp}" == "yes" ]; then - printf "\nBuilding the application...\n\n" - ./gradlew assembleDebug -fi - -if [ "${buildLocal}" == "no" ]; then - printf "\nCleaning up...\n\n" - rm -rf ../matrix-rust-sdk-"$date" -fi - -printf "\nDone!\n" From e6c707968363951bb63b6a89f571bc22d4f8c25c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:23:31 +0100 Subject: [PATCH 20/20] Update dependency io.sentry:sentry-android to v8.34.0 (#6280) 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 07b852ff96..537203e6b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -220,7 +220,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.34.3" -sentry = "io.sentry:sentry-android:8.33.0" +sentry = "io.sentry:sentry-android:8.34.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2"