From 404c26c62ea99eb6047a19d3af1db499455fb3e7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:15 +0200 Subject: [PATCH 01/30] Fetch and map the thread list items off the main thread The thread list subscription was launched in the room coroutine scope, which runs on the main thread, so the initial sync FFI call and the mapping of all the items and diffs were done on the main thread every time a room was opened. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/libraries/matrix/impl/room/JoinedRustRoom.kt | 1 + .../matrix/impl/room/threads/RustThreadsListService.kt | 5 +++++ .../matrix/impl/room/threads/RustThreadsListServiceTest.kt | 2 ++ 3 files changed, 8 insertions(+) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 17a3910c98..3be63f4f97 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -158,6 +158,7 @@ class JoinedRustRoom( inner = innerRoom.threadListService(), contentMapper = TimelineEventContentMapper(), roomCoroutineScope = roomCoroutineScope, + roomDispatcher = roomDispatcher, ) override val syncUpdateFlow = flow { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt index a74c5bc378..98248b183b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.map import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService class RustThreadsListService( private val inner: InnerThreadListService, private val roomCoroutineScope: CoroutineScope, + private val roomDispatcher: CoroutineDispatcher, private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), ) : ThreadsListService { private var itemSubscriptionJob: Job? = null @@ -63,6 +66,8 @@ class RustThreadsListService( updated.apply(diff, contentMapper) items.value = updated } + // Fetching and mapping the items can be costly, do not do it on the main thread + .flowOn(roomDispatcher) .launchIn(roomCoroutineScope) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt index 0fc9ad0603..fd9f311b95 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -122,6 +123,7 @@ class RustThreadsListServiceTest { ) = RustThreadsListService( inner = inner, roomCoroutineScope = backgroundScope, + roomDispatcher = StandardTestDispatcher(testScheduler), ) private fun aRustThreadListUpdate() = ThreadListUpdate.Append( From 149dedcc03000a1cb57ef5d47d16ea7bd7d0f3ee Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:16 +0200 Subject: [PATCH 02/30] Store the user profile off the main thread when it is updated by the SDK Same issue as when refreshing the profile manually: the profile listener stored the profile (Base64 encoding and session database access) from the session coroutine scope, which runs on the main thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a9eb22cc8b..a30d63111a 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 @@ -322,7 +322,7 @@ class RustMatrixClient( override fun onUpdate(profile: UserProfile) { val matrixUser = profile.map() _userProfile.tryEmit(matrixUser) - sessionCoroutineScope.launch { + sessionCoroutineScope.launch(sessionDispatcher) { storeUserProfile(matrixUser) } } From 9be2a31c5f9a759c9d030b413a856c6f3d21daa6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:16 +0200 Subject: [PATCH 03/30] Schedule the background search backfill off the main thread Checking if the work is already scheduled does a blocking WorkManager query, which was done on the main thread at session start. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 3 +++ .../libraries/matrix/impl/RustMatrixClientFactoryTest.kt | 4 ++++ 2 files changed, 7 insertions(+) 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 a30d63111a..84b372eb12 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 @@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -1034,6 +1035,8 @@ class RustMatrixClient( searchBackfillService.cancelPeriodicSearchBackfill() } } + // Checking the pending work is a blocking call, do not do it on the main thread + .flowOn(sessionDispatcher) .launchIn(sessionCoroutineScope) } } 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 0ed71e3bfe..d5d8c62515 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 @@ -33,7 +33,9 @@ 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 io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client @@ -41,6 +43,7 @@ import org.matrix.rustcomponents.sdk.ClientDelegate import java.io.File import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +@OptIn(ExperimentalCoroutinesApi::class) class RustMatrixClientFactoryTest { @Test fun test() = runTest { @@ -51,6 +54,7 @@ class RustMatrixClientFactoryTest { val result = sut.create(aSessionData()) assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org")) + runCurrent() // 2 workers are scheduled: one for the search backfill and one for the vacuum scheduleWorkersLambda.assertions().isCalledExactly(2) result.destroy() From 6efb10df11a36c2fb1ca379a6b3d33c45b0c8d84 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:17 +0200 Subject: [PATCH 04/30] Write the push history off the main thread The push history was written to the database synchronously, from the app coroutine scope which runs on the main thread, for every received push. Make PushHistoryService.onPushResult suspend and run all the database queries on the IO dispatcher. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/history/DefaultPushHistoryService.kt | 47 ++++++++++--------- .../push/impl/history/PushHistoryService.kt | 12 ++--- .../impl/history/FakePushHistoryService.kt | 2 +- 3 files changed, 33 insertions(+), 28 deletions(-) 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 ab6f01e423..f1c4c9d9a3 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,6 +14,7 @@ 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.coroutine.CoroutineDispatchers 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 @@ -23,23 +24,25 @@ 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 kotlinx.coroutines.withContext import kotlin.time.Instant @ContributesBinding(AppScope::class) class DefaultPushHistoryService( private val pushDatabase: PushDatabase, private val systemClock: SystemClock, + private val dispatchers: CoroutineDispatchers, @ApplicationContext context: Context, ) : PushHistoryService { private val powerManager = context.getSystemService() private val packageName = context.packageName - override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { - return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } } - override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { - return runCatchingExceptions { + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transaction { for (request in pushRequests) { pushDatabase.pushRequestQueries.insertPushRequest(request) @@ -48,8 +51,8 @@ class DefaultPushHistoryService( } } - override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { - return runCatchingExceptions { + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transactionWithResult { val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() @@ -57,14 +60,14 @@ class DefaultPushHistoryService( } } - override suspend fun removeOldPushRequests(sessionId: SessionId): Result { - return runCatchingExceptions { + override suspend fun removeOldPushRequests(sessionId: SessionId): Result = withContext(dispatchers.io) { + runCatchingExceptions { val keepAmount = 100L pushDatabase.pushRequestQueries.removeOldest(keepAmount) } } - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -91,19 +94,21 @@ class DefaultPushHistoryService( } }.takeIf { it.isNotEmpty() } - pushDatabase.pushHistoryQueries.insertPushHistory( - PushHistory( - pushDate = systemClock.epochMillis(), - providerInfo = providerInfo, - eventId = eventId?.value, - roomId = roomId?.value, - sessionId = sessionId?.value, - hasBeenResolved = if (hasBeenResolved) 1 else 0, - comment = finalComment, + withContext(dispatchers.io) { + pushDatabase.pushHistoryQueries.insertPushHistory( + PushHistory( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = eventId?.value, + roomId = roomId?.value, + sessionId = sessionId?.value, + hasBeenResolved = if (hasBeenResolved) 1 else 0, + comment = finalComment, + ) ) - ) - // Keep only the last 1_000 events - pushDatabase.pushHistoryQueries.removeOldest(1_000) + // Keep only the last 1_000 events + pushDatabase.pushHistoryQueries.removeOldest(1_000) + } } } 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 3996924322..298abbd441 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 @@ -20,7 +20,7 @@ interface PushHistoryService { * Create a new push history entry. * Do not use directly, prefer using the extension functions. */ - fun onPushResult( + suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -52,7 +52,7 @@ interface PushHistoryService { suspend fun removeOldPushRequests(sessionId: SessionId): Result } -fun PushHistoryService.onInvalidPushReceived( +suspend fun PushHistoryService.onInvalidPushReceived( providerInfo: String, data: String, ) = onPushResult( @@ -65,7 +65,7 @@ fun PushHistoryService.onInvalidPushReceived( comment = "Invalid or ignored push data:\n$data", ) -fun PushHistoryService.onUnableToRetrieveSession( +suspend fun PushHistoryService.onUnableToRetrieveSession( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -80,7 +80,7 @@ fun PushHistoryService.onUnableToRetrieveSession( comment = "Unable to retrieve session: $reason", ) -fun PushHistoryService.onUnableToResolveEvent( +suspend fun PushHistoryService.onUnableToResolveEvent( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -96,7 +96,7 @@ fun PushHistoryService.onUnableToResolveEvent( comment = "Unable to resolve event: $reason", ) -fun PushHistoryService.onSuccess( +suspend fun PushHistoryService.onSuccess( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -117,7 +117,7 @@ fun PushHistoryService.onSuccess( }, ) -fun PushHistoryService.onDiagnosticPush( +suspend fun PushHistoryService.onDiagnosticPush( providerInfo: String, ) = onPushResult( providerInfo = providerInfo, 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 aa9612717a..e7cd342514 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 @@ -30,7 +30,7 @@ class FakePushHistoryService( private val getPendingPushRequests: (SessionId, Instant?) -> Result> = { _, _ -> lambdaError() }, private val removeOldPushRequests: (SessionId) -> Result = { lambdaError() }, ) : PushHistoryService { - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, From 865cca67641b51b9c10a978e767ec720dc0695b8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:17 +0200 Subject: [PATCH 05/30] Load the push distributors off the main thread in the notification settings Getting the distributors queries the PackageManager and the Play Services, it was done during the first composition of the screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../NotificationSettingsPresenter.kt | 20 ++++++++++++------- .../NotificationSettingsPresenterTest.kt | 2 ++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index d2e5fc5b50..c08427f387 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingStateNoSuccess +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds @@ -76,6 +78,7 @@ class NotificationSettingsPresenter( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val featureFlagService: FeatureFlagService, + private val dispatchers: CoroutineDispatchers, ) : Presenter { // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // its `.tmp` file, but the persist+recreate window outside the copier is unprotected; @@ -111,16 +114,19 @@ class NotificationSettingsPresenter( } // List of PushProvider -> Distributor - val distributors = remember { - pushService.getAvailablePushProviders() - .flatMap { pushProvider -> - pushProvider.getDistributors().map { distributor -> - pushProvider to distributor + // Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread + val distributors by produceState(emptyList>()) { + value = withContext(dispatchers.io) { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } } - } + } } // List of Distributors - val availableDistributors = remember { + val availableDistributors = remember(distributors) { distributors.map { it.second }.toImmutableList() } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index f189949cdc..e37d39d775 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope @@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest { stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, featureFlagService = featureFlagService, + dispatchers = testCoroutineDispatchers(), ) } } From 6049f25a3e888646de34318f73909a0068b6db86 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:18 +0200 Subject: [PATCH 06/30] Create the LocalMedia off the main thread when picking or sharing media LocalMediaFactory.createFromUri queries the ContentResolver and can decode the media, it was called on the main thread from the picker callbacks and when sharing a file. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../MessageComposerPresenter.kt | 89 +++++++++++-------- ...essageComposerPresenterSlashCommandTest.kt | 1 + .../MessageComposerPresenterTest.kt | 1 + 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 2974b99696..5ffd90b3ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes @@ -145,6 +147,7 @@ class MessageComposerPresenter( private val featureFlagService: FeatureFlagService, private val contentScannerService: ContentScannerService, private val contentValidationCache: EventContentValidationCache, + private val dispatchers: CoroutineDispatchers, ) : Presenter { @AssistedFactory interface Factory { @@ -193,22 +196,22 @@ class MessageComposerPresenter( .collectAsState(initial = false) val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> - handlePickedMedia(uri, mimeType) + localCoroutineScope.handlePickedMedia(uri, mimeType) } val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> - handlePickedMediaList(uris) + localCoroutineScope.handlePickedMediaList(uris) } val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> - handlePickedMediaList(uris, sendAsFile = true) + localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true) } val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) + localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Jpeg) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg) } val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Mp4) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4) } val isFullScreen = rememberSaveable { mutableStateOf(false) @@ -286,17 +289,16 @@ class MessageComposerPresenter( } is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - sessionCoroutineScope.sendAttachment( - attachment = Attachment.Media( - localMedia = localMediaFactory.createFromUri( - uri = event.uri, - mimeType = null, - name = null, - formattedFileSize = null - ), - ), - inReplyToEventId = inReplyToEventId, - ) + sessionCoroutineScope.launch { + val localMedia = createLocalMedia( + uri = event.uri, + mimeType = null, + ) + sendAttachment( + attachment = Attachment.Media(localMedia), + inReplyToEventId = inReplyToEventId, + ) + } resetComposerModeAfterAttaching() } @@ -640,17 +642,28 @@ class MessageComposerPresenter( } } - private fun handlePickedMedia( - uri: Uri?, - mimeType: String? = null, - sendAsFile: Boolean = false, - ) { - uri ?: return - val localMedia = localMediaFactory.createFromUri( + // Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread + private suspend fun createLocalMedia( + uri: Uri, + mimeType: String?, + ) = withContext(dispatchers.io) { + localMediaFactory.createFromUri( uri = uri, mimeType = mimeType, name = null, - formattedFileSize = null + formattedFileSize = null, + ) + } + + private fun CoroutineScope.handlePickedMedia( + uri: Uri?, + mimeType: String? = null, + sendAsFile: Boolean = false, + ) = launch { + uri ?: return@launch + val localMedia = createLocalMedia( + uri = uri, + mimeType = mimeType, ) val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile) val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId @@ -659,7 +672,7 @@ class MessageComposerPresenter( resetComposerModeAfterAttaching() } - private fun handlePickedMediaList( + private fun CoroutineScope.handlePickedMediaList( uris: List, sendAsFile: Boolean = false, ) { @@ -668,19 +681,19 @@ class MessageComposerPresenter( handlePickedMedia(uris.first(), sendAsFile = sendAsFile) return } - val attachments = uris.map { uri -> - val localMedia = localMediaFactory.createFromUri( - uri = uri, - mimeType = null, - name = null, - formattedFileSize = null, - ) - Attachment.Media(localMedia, sendAsFile = sendAsFile) - }.toImmutableList() - val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) + launch { + val attachments = uris.map { uri -> + val localMedia = createLocalMedia( + uri = uri, + mimeType = null, + ) + Attachment.Media(localMedia, sendAsFile = sendAsFile) + }.toImmutableList() + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) - resetComposerModeAfterAttaching() + resetComposerModeAfterAttaching() + } } private fun resetComposerModeAfterAttaching() { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index a8f6c338f5..4535607cb6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 3253e6088d..74b8047449 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled From 259fc222da618d9da9abae9106e7e526c242f60a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 09:45:23 +0200 Subject: [PATCH 07/30] Import strings for the swipe account banner --- features/home/impl/src/main/res/values/localazy.xml | 2 ++ tools/localazy/config.json | 1 + 2 files changed, 3 insertions(+) diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index 4f3420fa23..d1983621ac 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -8,6 +8,8 @@ "Your chats are automatically backed up with end-to-end encryption. To restore this backup and retain your digital identity when you lose access to all your devices, you will need your recovery key." "Get recovery key" "Back up your chats" + "Swipe up or down on your avatar to switch between your accounts without going to the app settings." + "Swipe your avatar to switch accounts" "Confirm your recovery key to maintain access to your key storage and message history." "Enter your recovery key" "Forgot your recovery key?" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index a46ae3adc1..c4e53a55c2 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -211,6 +211,7 @@ "banner\\.set_up_recovery\\..*", "banner\\.battery_optimization\\..*", "banner\\.new_sound\\..*", + "banner\\.swipe_avatar\\..*", "full_screen_intent_banner_.*", "screen_migration_.*", "screen_invites_.*" From 14710599a72566a77300175899f59844cc2945b1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 10:06:43 +0200 Subject: [PATCH 08/30] Announce the swipe gesture on the account avatar Display a banner on the room list when 2 or more accounts are logged in, to let the user know that they can swipe their avatar to switch account. The banner is stored as a new Announcement.SwipeAvatarToSwitchAccount, and is dismissed when the user taps on it, or when the user switches account by swiping their avatar. The banner is displayed at the same place as the new notification sound banner, but never at the same time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/announcement/api/Announcement.kt | 2 + .../impl/DefaultAnnouncementService.kt | 8 +++- .../impl/store/DefaultAnnouncementStore.kt | 5 ++ .../impl/DefaultAnnouncementServiceTest.kt | 28 +++++++++-- .../impl/store/InMemoryAnnouncementStore.kt | 4 ++ .../features/home/impl/HomePresenter.kt | 3 ++ .../home/impl/components/HomeTopBar.kt | 10 ++-- .../impl/components/RoomListContentView.kt | 9 +++- .../home/impl/components/SwipeAvatarBanner.kt | 43 +++++++++++++++++ .../RoomListContentStatePreviewParam.kt | 5 ++ .../home/impl/roomlist/RoomListEvent.kt | 1 + .../home/impl/roomlist/RoomListPresenter.kt | 18 +++++++ .../home/impl/roomlist/RoomListState.kt | 1 + .../features/home/impl/HomePresenterTest.kt | 34 +++++++++++++ .../impl/roomlist/RoomListPresenterTest.kt | 48 +++++++++++++++++++ .../home/impl/roomlist/RoomListViewTest.kt | 21 ++++++++ 16 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index 69def559a1..56a09a1f11 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -19,4 +19,6 @@ sealed interface Announcement { data object NewNotificationSound : Announcement data object MultiAccount : Announcement + + data object SwipeAvatarToSwitchAccount : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index 3c36989571..caf0001ee3 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -34,6 +34,7 @@ class DefaultAnnouncementService( announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) + Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement) } } @@ -46,7 +47,8 @@ class DefaultAnnouncementService( flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), announcementStore.announcementStatusFlow(Announcement.MultiAccount), - ) { _, newNotificationSoundStatus, multiAccountStatus -> + announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount), + ) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus -> buildList { if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) @@ -56,6 +58,10 @@ class DefaultAnnouncementService( if (multiAccountStatus != AnnouncementStatus.Shown) { add(Announcement.MultiAccount) } + // The swipe avatar announcement is pending until it is dismissed, or the user switches account by swiping. + if (swipeAvatarToSwitchAccountStatus != AnnouncementStatus.Shown) { + add(Announcement.SwipeAvatarToSwitchAccount) + } } } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 2235285bcf..4e2e11ee2e 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") +private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement") @ContributesBinding(AppScope::class) class DefaultAnnouncementStore( @@ -42,10 +43,13 @@ class DefaultAnnouncementStore( // - NewNotificationSound is opted in by AppMigration08 // - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters // the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement. + // - SwipeAvatarToSwitchAccount is NeverShown by default, which means pending. It is set to Shown when the user + // dismisses the banner, or switches account by swiping their avatar. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown Announcement.MultiAccount -> AnnouncementStatus.NeverShown + Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal @@ -62,4 +66,5 @@ private fun Announcement.toKey() = when (this) { Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey Announcement.MultiAccount -> multiAccountKey + Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index 18442b362d..f9df1dbaaf 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -67,6 +67,22 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) } + @Test + fun `when showing SwipeAvatarToSwitchAccount announcement, announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + @Test fun `test announcementsToShowFlow`() = runTest { val announcementStore = InMemoryAnnouncementStore() @@ -74,16 +90,18 @@ class DefaultAnnouncementServiceTest { announcementStore = announcementStore, ) sut.announcementsToShowFlow().test { - // MultiAccount is pending by default - assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) + // MultiAccount and SwipeAvatarToSwitchAccount are pending by default + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) // Still pending - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) - assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount) + announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown) assertThat(awaitItem()).isEmpty() } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index 9b5e762eb6..14a8eea9ab 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -17,10 +17,12 @@ class InMemoryAnnouncementStore( initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) + private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status @@ -34,11 +36,13 @@ class InMemoryAnnouncementStore( fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown multiAccountAnnouncement.value = AnnouncementStatus.NeverShown + swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement Announcement.MultiAccount -> multiAccountAnnouncement + Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95c98a4aec..abeb7f890f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -111,6 +111,9 @@ class HomePresenter( currentHomeNavigationBarItemOrdinal = event.item.ordinal } is HomeEvent.SwitchToAccount -> coroutineState.launch { + // The user has switched account by swiping their avatar, so they know the gesture. + // Do it before switching, since it can stop this coroutine. + announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) sessionStore.setLatestSession(event.sessionId.value) } HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index e799f44e25..291b9f3c00 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -92,6 +92,7 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.filter @Composable fun HomeTopBar( @@ -307,9 +308,12 @@ private fun NavigationIcon( // Listen to page changes and switch account if needed val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.collect { page -> - latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) - } + snapshotFlow { pagerState.settledPage } + // The current account is on page 1, so there is nothing to switch to + .filter { page -> page != 1 } + .collect { page -> + latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) + } } VerticalPager( state = pagerState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 5fc6d4c85e..68d258431d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -253,7 +253,7 @@ private fun RoomsViewList( } } // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > sound-unavailable. At most one renders at a time. + // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. SecurityBannerState.None -> when { state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { item { @@ -272,6 +272,13 @@ private fun RoomsViewList( ) } } + state.showSwipeAvatarBanner -> { + item { + SwipeAvatarBanner( + onDismissClick = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, + ) + } + } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt new file mode 100644 index 0000000000..bf1df99aec --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt @@ -0,0 +1,43 @@ +/* + * 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.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun SwipeAvatarBanner( + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Announcement( + modifier = modifier.roomListBannerPadding(), + title = stringResource(R.string.banner_swipe_avatar_title), + description = stringResource(R.string.banner_swipe_avatar_content), + type = AnnouncementType.Actionable( + actionText = stringResource(CommonStrings.action_ok), + onActionClick = onDismissClick, + onDismissClick = onDismissClick, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun SwipeAvatarBannerPreview() = ElementPreview { + SwipeAvatarBanner( + onDismissClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt index e24074e042..bcc369d1bc 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt @@ -30,12 +30,16 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider = aRoomListRoomSummaryList(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), @@ -45,6 +49,7 @@ internal fun aRoomsContentState( ) = RoomListContentState.Rooms( securityBannerState = securityBannerState, showNewNotificationSoundBanner = showNewNotificationSoundBanner, + showSwipeAvatarBanner = showSwipeAvatarBanner, showUnreadCount = showUnreadCount, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, batteryOptimizationState = batteryOptimizationState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt index 49d033682f..d912842b64 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt @@ -16,6 +16,7 @@ sealed interface RoomListEvent { data object DismissRequestVerificationPrompt : RoomListEvent data object DismissBanner : RoomListEvent data object DismissNewNotificationSoundBanner : RoomListEvent + data object DismissSwipeAvatarBanner : RoomListEvent data object ToggleSearchResults : RoomListEvent data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 68aba21188..611f1e8ee2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -64,6 +65,7 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -89,6 +91,7 @@ class RoomListPresenter( private val spaceFiltersPresenter: Presenter, private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : Presenter { private val encryptionService = client.encryptionService @@ -112,6 +115,15 @@ class RoomListPresenter( announcements.contains(Announcement.NewNotificationSound) } }.collectAsState(false) + val showSwipeAvatarBanner by remember { + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + // The swipe gesture on the avatar is only available when there are several accounts + sessions.size >= 2 && announcements.contains(Announcement.SwipeAvatarToSwitchAccount) + } + }.collectAsState(false) // Avatar indicator val hideInvitesAvatar by client.rememberHideInvitesAvatar() @@ -133,6 +145,9 @@ class RoomListPresenter( RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch { announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) } + RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch { + announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) + } RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility) is RoomListEvent.ShowContextMenu -> { coroutineScope.showContextMenu(event, contextMenu) @@ -176,6 +191,7 @@ class RoomListPresenter( val contentState = roomListContentState( securityBannerDismissed, showNewNotificationSoundBanner, + showSwipeAvatarBanner, showUnreadCount, showAllActivity, ) @@ -235,6 +251,7 @@ class RoomListPresenter( private fun roomListContentState( securityBannerDismissed: Boolean, showNewNotificationSoundBanner: Boolean, + showSwipeAvatarBanner: Boolean, showUnreadCount: Boolean, showAllActivity: Boolean, ): RoomListContentState { @@ -265,6 +282,7 @@ class RoomListPresenter( RoomListContentState.Rooms( securityBannerState = securityBannerState, showNewNotificationSoundBanner = showNewNotificationSoundBanner, + showSwipeAvatarBanner = showSwipeAvatarBanner, showUnreadCount = showUnreadCount, fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index bb1ee631a1..6d3d85ab56 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -73,6 +73,7 @@ sealed interface RoomListContentState { val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val batteryOptimizationState: BatteryOptimizationState, val showNewNotificationSoundBanner: Boolean, + val showSwipeAvatarBanner: Boolean, val showUnreadCount: Boolean, val summaries: ImmutableList, val seenRoomInvites: ImmutableSet, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index cf592d9e7e..9ff324f7b8 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -28,6 +28,8 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -39,7 +41,9 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -245,6 +249,36 @@ class HomePresenterTest { .with(value(Announcement.MultiAccount)) } } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - switching account dismisses the swipe avatar announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val setLatestSessionResult = lambdaRecorder { } + val presenter = createHomePresenter( + client = FakeMatrixClient(sessionId = A_SESSION_ID), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ), + updateUserProfileResult = { _, _, _, _ -> }, + setLatestSessionResult = setLatestSessionResult, + ), + announcementService = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + ) + presenter.test { + val state = expectMostRecentItem() + state.eventSink(HomeEvent.SwitchToAccount(A_SESSION_ID_2)) + runCurrent() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.SwipeAvatarToSwitchAccount)) + setLatestSessionResult.assertions().isCalledOnce() + .with(value(A_SESSION_ID_2.value)) + } + } } internal fun createHomePresenter( diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index f73e39b8d7..f45e0fa7bf 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -56,6 +56,7 @@ 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.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService @@ -72,6 +73,9 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS import io.element.android.libraries.push.api.battery.aBatteryOptimizationState import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +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.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher @@ -645,6 +649,48 @@ class RoomListPresenterTest { } } + @Test + fun `present - swipe avatar banner`() = runTest { + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(aRoomSummary())), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList }, + subscribeToVisibleRoomsLambda = { }, + ) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.SwipeAvatarToSwitchAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)), + ) + val presenter = createRoomListPresenter( + client = matrixClient, + announcementService = announcementService, + sessionStore = sessionStore, + ) + presenter.test { + skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags + val state = awaitItem() + // Only one account, the banner is not displayed + assertThat(state.contentAsRooms().showSwipeAvatarBanner).isFalse() + sessionStore.addSession(aSessionData(sessionId = A_SESSION_ID_2.value)) + assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isTrue() + state.eventSink(RoomListEvent.DismissSwipeAvatarBanner) + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.SwipeAvatarToSwitchAccount)) + // Simulate service updating the value + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isFalse() + } + } + @Test fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest { val roomList = FakeDynamicRoomList( @@ -724,6 +770,7 @@ class RoomListPresenterTest { announcementService: AnnouncementService = FakeAnnouncementService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), markRoomAsRead: MarkRoomAsRead? = null, + sessionStore: SessionStore = InMemorySessionStore(), ) = RoomListPresenter( client = client, leaveRoomPresenter = { leaveRoomState }, @@ -756,5 +803,6 @@ class RoomListPresenterTest { announcementService = announcementService, coldStartWatcher = FakeAnalyticsColdStartWatcher(), featureFlagService = featureFlagService, + sessionStore = sessionStore, ) } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 3cff7ca28b..2c158d3ef9 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -94,6 +94,27 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } + @Test + fun `clicking on close swipe avatar banner emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(showSwipeAvatarBanner = true), + // Ignore the visible range updates + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, + ) + ) + + eventsRecorder.assertEmpty() + val close = activity!!.getString(CommonStrings.action_close) + onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) + } + @Test fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() From 8923ba1e1b83b6ed13f284b1022e59e9c943811c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 10:36:13 +0200 Subject: [PATCH 09/30] Animate the dismissal of the banners in the room list Add DismissibleBanner, which makes a banner shrink and fade out when it is dismissed, and invokes the dismiss callback when the animation is over, so that the banner is removed from the list when its height is 0. Apply it to all the dismissible banners of the room list. The banners are now rendered in a slot which is always the first item of the room list, so that the rooms, which do not have a key, keep the same index and the scroll position is preserved when a banner is removed. The visible range of rooms sent to the SDK does not include this slot anymore. FullScreenIntentPermissionBanner and BatteryOptimizationBanner now take callbacks instead of their state, like the other banners. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/BatteryOptimizationBanner.kt | 13 +- .../home/impl/components/DismissibleBanner.kt | 78 +++++++++ .../FullScreenIntentPermissionBanner.kt | 15 +- .../impl/components/RoomListContentView.kt | 159 ++++++++++++------ .../home/impl/roomlist/RoomListViewTest.kt | 31 ++-- 5 files changed, 217 insertions(+), 79 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt index 95826665fe..3feabca8b0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt @@ -16,13 +16,11 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent -import io.element.android.libraries.push.api.battery.BatteryOptimizationState -import io.element.android.libraries.push.api.battery.aBatteryOptimizationState @Composable internal fun BatteryOptimizationBanner( - state: BatteryOptimizationState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { Announcement( @@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner( description = stringResource(R.string.banner_battery_optimization_content_android), type = AnnouncementType.Actionable( actionText = stringResource(R.string.banner_battery_optimization_submit_android), - onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, + onActionClick = onActionClick, + onDismissClick = onDismissClick, ), ) } @@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner( @Composable internal fun BatteryOptimizationBannerPreview() = ElementPreview { BatteryOptimizationBanner( - state = aBatteryOptimizationState(), + onActionClick = {}, + onDismissClick = {}, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt new file mode 100644 index 0000000000..1c743ec8dd --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt @@ -0,0 +1,78 @@ +/* + * 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.home.impl.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.first + +/** + * Wraps a banner of the room list so that it shrinks and fades out when it is dismissed. + * [onDismiss] is invoked once the animation is over, so that the banner is removed from the list + * when its height is 0, and the items below it are not moved abruptly. + * + * @param onDismiss invoked when the banner has been dismissed and the animation is over. + * @param modifier the modifier to apply to the banner container. + * @param content the banner, which must invoke the provided lambda when the user dismisses it. + */ +@Composable +internal fun DismissibleBanner( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (onDismissClick: () -> Unit) -> Unit, +) { + val visibleState = remember { MutableTransitionState(true) } + val latestOnDismiss by rememberUpdatedState(onDismiss) + var isDismissNotified by remember { mutableStateOf(false) } + fun notifyDismiss() { + if (!isDismissNotified) { + isDismissNotified = true + latestOnDismiss() + } + } + LaunchedEffect(visibleState) { + snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } + notifyDismiss() + } + DisposableEffect(visibleState) { + onDispose { + // The item can be disposed during the animation, for instance if the user scrolls the list + if (!visibleState.targetState) { + notifyDismiss() + } + } + } + AnimatedVisibility( + visibleState = visibleState, + modifier = modifier, + enter = EnterTransition.None, + exit = shrinkVertically( + animationSpec = tween(), + shrinkTowards = Alignment.CenterVertically, + ) + fadeOut( + animationSpec = tween(), + ), + ) { + content { visibleState.targetState = false } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt index f2826415d2..ae5a25a623 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt @@ -16,14 +16,12 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState -import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.ui.strings.CommonStrings @Composable fun FullScreenIntentPermissionBanner( - state: FullScreenIntentPermissionsState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { Announcement( @@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner( description = stringResource(R.string.full_screen_intent_banner_message), type = AnnouncementType.Actionable( actionText = stringResource(CommonStrings.action_continue), - onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + onActionClick = onActionClick, ), modifier = modifier.roomListBannerPadding(), ) @@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner( @Composable internal fun FullScreenIntentPermissionBannerPreview() { ElementPreview { - FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) + FullScreenIntentPermissionBanner( + onActionClick = {}, + onDismissClick = {}, + ) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 68d258431d..874f4f4784 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -52,6 +52,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -161,16 +163,24 @@ private fun EmptyView( Box { when (state.securityBannerState) { SecurityBannerState.SetUpRecovery -> { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.RecoveryKeyConfirmation -> { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.None -> Unit } @@ -228,60 +238,26 @@ private fun RoomsViewList( modifier: Modifier = Modifier, ) { OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> - eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) + // The first item of the list is the banner slot, remove it to get the range of the rooms + val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..(visibleRange.last - 1) + eventSink(RoomListEvent.UpdateVisibleRange(roomsRange)) } LazyColumn( state = lazyListState, modifier = modifier, contentPadding = contentPadding, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - item { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - item { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - item { - FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - item { - BatteryOptimizationBanner(state = state.batteryOptimizationState) - } - } - state.showNewNotificationSoundBanner -> { - item { - NewNotificationSoundBanner( - onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) - } - } - state.showSwipeAvatarBanner -> { - item { - SwipeAvatarBanner( - onDismissClick = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) - } - } - } + // The banner slot is always the first item of the list, even when there is no banner to display, + // so that the rooms keep the same index when a banner is added or removed. Since the rooms do not + // have a key, this is required to keep the scroll position. + item { + RoomsViewBanner( + state = state, + eventSink = eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + ) } - // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // is moved to the top of the list. itemsIndexed( @@ -305,6 +281,79 @@ private fun RoomsViewList( } } +@Composable +private fun RoomsViewBanner( + state: RoomListContentState.Rooms, + eventSink: (RoomListEvent) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, +) { + when (state.securityBannerState) { + SecurityBannerState.SetUpRecovery -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } + } + SecurityBannerState.RecoveryKeyConfirmation -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } + } + // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > + // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. + SecurityBannerState.None -> when { + state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { + DismissibleBanner( + onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, + ) { onDismissClick -> + FullScreenIntentPermissionBanner( + onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + ) + } + } + state.batteryOptimizationState.shouldDisplayBanner -> { + DismissibleBanner( + onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) }, + ) { onDismissClick -> + BatteryOptimizationBanner( + onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, + onDismissClick = onDismissClick, + ) + } + } + state.showNewNotificationSoundBanner -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, + ) { onDismissClick -> + NewNotificationSoundBanner( + onDismissClick = onDismissClick, + ) + } + } + state.showSwipeAvatarBanner -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, + ) { onDismissClick -> + SwipeAvatarBanner( + onDismissClick = onDismissClick, + ) + } + } + } + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 2c158d3ef9..e93aef71f5 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -51,9 +51,10 @@ class RoomListViewTest : RobolectricTest() { ) ) + // The banner is not included in the range, only the rooms are eventsRecorder.assertList( listOf( - RoomListEvent.UpdateVisibleRange(0..5), + RoomListEvent.UpdateVisibleRange(0..4), ) ) } @@ -64,15 +65,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -82,15 +87,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -100,7 +109,7 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(showSwipeAvatarBanner = true), - // Ignore the visible range updates + // Ignore the visible range updates, which are also emitted when the banner is shrinking eventSink = { event -> if (event !is RoomListEvent.UpdateVisibleRange) { eventsRecorder(event) @@ -112,6 +121,8 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertEmpty() val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) } From 0dce4b9e493eae28c1bdc8c4d63d63c4fe57b7e0 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 13 Oct 2026 10:11:22 +0000 Subject: [PATCH 10/30] Update screenshots --- ...tures.home.impl.components_RoomListContentView_Day_6_en.png | 3 +++ ...res.home.impl.components_RoomListContentView_Night_6_en.png | 3 +++ ...eatures.home.impl.components_SwipeAvatarBanner_Day_0_en.png | 3 +++ ...tures.home.impl.components_SwipeAvatarBanner_Night_0_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png new file mode 100644 index 0000000000..2e8b5c11be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bae5109ecc9377e0f1d299a69bb5cc66d7a9432de3590d4a5e308abde7db61a9 +size 64511 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png new file mode 100644 index 0000000000..e5d57b8a2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b58c0184ac8b9ff9b59c53125db66b890efbf09443f53e1f9091e2894519e4 +size 63677 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png new file mode 100644 index 0000000000..3b74f9c1ba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a9bab9bae4bd59e54e7dc2897367b110458baf184d9abb4ad801b76d6cb1432 +size 28089 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png new file mode 100644 index 0000000000..ed0bcd0d48 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ead5c82cfb8ccac0a666e3c9d41121206932457ffd1047f268390b4f71ef198e +size 27439 From f187409db3340d5afe2603e3f0e6eeff6a5419d0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 12:55:50 +0200 Subject: [PATCH 11/30] Apply suggestion: 'rangeTo' or the '..' call should be replaced with '..<' --- .../features/home/impl/components/RoomListContentView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 874f4f4784..09dc51d68f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -239,7 +239,7 @@ private fun RoomsViewList( ) { OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> // The first item of the list is the banner slot, remove it to get the range of the rooms - val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..(visibleRange.last - 1) + val roomsRange = (visibleRange.first - 1).coerceAtLeast(0).. Date: Tue, 13 Oct 2026 17:56:01 +0000 Subject: [PATCH 12/30] Update sqldelight to v2.4.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 c83ab600ab..cf80c35602 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ coil = "3.6.3" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" -sqldelight = "2.4.0" +sqldelight = "2.4.1" wysiwyg = "2.42.1" telephoto = "0.19.0" maplibre-compose = "0.19.0" From 0149e6f65b86e28f6c608270b46f0826e84c291c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 10:11:07 +0200 Subject: [PATCH 13/30] Do not mix the homeserver MapTiler config with the build time one The map URL builders now only use the MapTilerConfig they are given, falling back to the public MapTiler defaults for the missing values, instead of the values defined at build time. This avoids for instance sending the build time API key to a tile server provided by the homeserver. When there is no config, no static map URL builder is created, so no request is performed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/location/api/StaticMapView.kt | 6 +- .../location/api/internal/MapTilerDefaults.kt | 33 ++++++++++ .../internal/MapTilerStaticMapUrlBuilder.kt | 16 ++--- .../MapTilerTileServerStyleUriBuilder.kt | 27 ++------ .../api/internal/StaticMapUrlBuilder.kt | 10 +-- .../api/internal/TileServerStyleUriBuilder.kt | 19 +++--- .../MapTilerStaticMapUrlBuilderTest.kt | 33 ++++++++++ .../MapTilerTileServerStyleUriBuilderTest.kt | 61 ++++++++----------- 8 files changed, 118 insertions(+), 87 deletions(-) create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index f6a70f6653..2e8e8b5a60 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -152,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent( Triple(painter, state, ContentScale.Crop) } else { val painter = rememberAsyncImagePainter( - model = if (constraints.isZero) { - // Avoid building a URL if any of the size constraints is zero + model = if (builder == null || constraints.isZero) { + // Avoid building a URL if there is no map provider or if any of the size constraints is zero null } else { ImageRequest.Builder(context) @@ -195,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent( else -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), - canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, + canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, height = maxHeight, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt new file mode 100644 index 0000000000..509278b3dd --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt @@ -0,0 +1,33 @@ +/* + * 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.location.api.internal + +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig + +/** + * Public MapTiler defaults, used when a [MapTilerConfig] does not define a value. + * Values from another configuration (e.g. the build time one) must never be used as a fallback, + * to avoid sending an API key to a server it was not meant for. + */ +internal object MapTilerDefaults { + const val BASE_URL = "https://api.maptiler.com/maps" + const val LIGHT_MAP_ID = "basic-v2" + const val DARK_MAP_ID = "basic-v2-dark" +} + +internal fun MapTilerConfig.baseUrlOrDefault(): String { + return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL +} + +internal fun MapTilerConfig.lightMapIdOrDefault(): String { + return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID +} + +internal fun MapTilerConfig.darkMapIdOrDefault(): String { + return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index cd339f67b0..c19d305f3d 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -9,7 +9,6 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig import kotlin.math.roundToInt /** @@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder( private val lightMapId: String, private val darkMapId: String, ) : StaticMapUrlBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) - constructor(mapTilerConfig: MapTilerConfig) : this( - baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"), - apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY, - lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = mapTilerConfig.baseUrlOrDefault(), + apiKey = mapTilerConfig.apiKey, + lightMapId = mapTilerConfig.lightMapIdOrDefault(), + darkMapId = mapTilerConfig.darkMapIdOrDefault(), ) override fun build( diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index d817a3307f..fc7ddc3fe0 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -11,33 +11,18 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig - -internal class MapTilerTileServerStyleUriBuilder( - private val baseUrl: String, - private val apiKey: String, - private val lightMapId: String, - private val darkMapId: String, -) : TileServerStyleUriBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) +internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder { override fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String { - val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl - val apiKey = customMapTilerConfig?.apiKey ?: apiKey + val baseUrl = mapTilerConfig.baseUrlOrDefault() val mapId = if (darkMode) { - customMapTilerConfig?.darkStyleId ?: darkMapId + mapTilerConfig.darkMapIdOrDefault() } else { - customMapTilerConfig?.lightStyleId ?: lightMapId + mapTilerConfig.lightMapIdOrDefault() } - - return "$baseUrl/$mapId/style.json?key=$apiKey" + return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}" } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 31992b1dd6..553a4ed160 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -36,17 +36,13 @@ interface StaticMapUrlBuilder { density: Float, ): String - /** Whether a map provider is configured for this build; the URLs are unusable when it is not. */ + /** Whether an API key is configured; the URLs are unusable when it is not. */ fun isServiceAvailable(): Boolean } @Composable -fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder { +fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? { return remember(mapTilerConfig) { - if (mapTilerConfig != null) { - MapTilerStaticMapUrlBuilder(mapTilerConfig) - } else { - MapTilerStaticMapUrlBuilder() - } + mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) } } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 67936525e5..6195f8fad3 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder { /** * Builds the style URI the map view loads its tiles from. * - * @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available. + * @param mapTilerConfig the MapTiler configuration to use. * @param darkMode whether to request the dark map style. */ fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String } @@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder { */ @Composable fun rememberTileStyleUrl( - customMapTileConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig?, ): String { val darkMode = !ElementTheme.isLightTheme - return remember(darkMode, customMapTileConfig) { - MapTilerTileServerStyleUriBuilder().build( - customMapTilerConfig = customMapTileConfig, - darkMode = darkMode, - ) + return remember(darkMode, mapTilerConfig) { + // Without config, the map is not displayed, so the style URL is not used. + mapTilerConfig?.let { + MapTilerTileServerStyleUriBuilder().build( + mapTilerConfig = it, + darkMode = darkMode, + ) + }.orEmpty() } } diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt index 3d17107104..c46b7a41d5 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerStaticMapUrlBuilderTest { @@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest { assertThat(builder.isServiceAvailable()).isTrue() } + @Test + fun `builder created from a config only uses the config values`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", + ) + ) + assertThat(configBuilder.isServiceAvailable()).isTrue() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f) + ).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright") + } + + @Test + fun `builder created from a partial config falls back to the public MapTiler defaults`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ) + ) + assertThat(configBuilder.isServiceAvailable()).isFalse() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f) + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright") + } + @Test fun `isServiceAvailable returns false if api key is empty`() { val builderWithoutKey = MapTilerStaticMapUrlBuilder( diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index c153ab1ce4..12c3a7ec63 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { - private val builder = MapTilerTileServerStyleUriBuilder( - baseUrl = "https://base.url", - apiKey = "anApiKey", - lightMapId = "aLightMapId", - darkMapId = "aDarkMapId", + private val builder = MapTilerTileServerStyleUriBuilder() + + private val config = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", ) @Test fun `light map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = false, ) - ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY") } @Test fun `dark map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = true, ) - ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY") } @Test - fun `custom map uri light`() { + fun `missing values fall back to the public MapTiler defaults`() { + val partialConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = null, + darkStyleId = " ", + baseUrl = " ", + ) assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = false, ) - ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY") - } - - @Test - fun `custom map uri dark`() { + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY") assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = true, ) - ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY") + ).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY") } @Test - fun `blank custom map uri falls back to the built-in style`() { + fun `blank api key is kept as is`() { assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = " " - ), + mapTilerConfig = config.copy(apiKey = ""), darkMode = false, ) - ).isEqualTo("https://base.url/light/style.json?key=API_KEY") + ).isEqualTo("https://custom.url/maps/light/style.json?key=") } } From 799f60b955b9a8cb2c57b582c23453b32ce59c11 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 10:11:08 +0200 Subject: [PATCH 14/30] Generate null MapTiler BuildConfig values when not provided Remove the default values from the Gradle file, the public MapTiler defaults are now only defined in MapTilerDefaults. buildConfigFieldStr now accepts a null value. Co-Authored-By: Claude Opus 5.5 (1M context) --- features/location/api/build.gradle.kts | 13 ++++--------- .../location/api/LocalMapTilerConfig.kt | 19 +++++++++---------- .../impl/DefaultLocationServiceTest.kt | 5 ++--- .../extension/VariantDimensionExtension.kt | 7 +++++-- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 36dbfdb139..811c0d89b7 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { buildConfigFieldStr( name = "MAPTILER_BASE_URL", - value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" + value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL, ) buildConfigFieldStr( name = "MAPTILER_API_KEY", @@ -35,8 +35,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") ?: readLocalProperty("services.maptiler.apikey") - } - ?: "" + }, ) buildConfigFieldStr( name = "MAPTILER_LIGHT_MAP_ID", @@ -45,9 +44,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") ?: readLocalProperty("services.maptiler.lightMapId") - } - // fall back to maptiler's default light map. - ?: "basic-v2" + }, ) buildConfigFieldStr( name = "MAPTILER_DARK_MAP_ID", @@ -56,9 +53,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") ?: readLocalProperty("services.maptiler.darkMapId") - } - // fall back to maptiler's default dark map. - ?: "basic-v2-dark" + }, ) } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index 00f9d5190c..bcaa5ac49c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -19,14 +19,13 @@ val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() } * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. */ fun buildTimeMapTilerConfig(): MapTilerConfig? { - return BuildConfig.MAPTILER_API_KEY - .takeIf { it.isNotEmpty() } - ?.let { apiKey -> - MapTilerConfig( - apiKey = apiKey, - lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, - baseUrl = BuildConfig.MAPTILER_BASE_URL - ) - } + // BuildConfig fields are null when not provided at build time. + val apiKey: String? = BuildConfig.MAPTILER_API_KEY + if (apiKey.isNullOrEmpty()) return null + return MapTilerConfig( + apiKey = apiKey, + lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = BuildConfig.MAPTILER_BASE_URL, + ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt index 00c7fc89b5..8a81895e1f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig -import io.element.android.features.location.api.BuildConfig import io.element.android.features.location.api.buildTimeMapTilerConfig import io.element.android.libraries.matrix.test.AN_EXCEPTION import kotlinx.coroutines.CoroutineScope @@ -30,7 +29,7 @@ class DefaultLocationServiceTest { ) runCurrent() assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) - assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } @Test @@ -40,7 +39,7 @@ class DefaultLocationServiceTest { ) runCurrent() assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) - assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } @Test diff --git a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt index ee43b88154..6ae99db5e5 100644 --- a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt +++ b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt @@ -10,14 +10,17 @@ package extension import com.android.build.api.dsl.VariantDimension +/** + * Adds a String field to the generated BuildConfig class. If [value] is null, the field will be null. + */ fun VariantDimension.buildConfigFieldStr( name: String, - value: String, + value: String?, ) { buildConfigField( type = "String", name = name, - value = "\"$value\"" + value = value?.let { "\"$it\"" } ?: "null" ) } From 6c1cc4e3f7a05d265a7c23e7c2e7104bfb8ebe08 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 10:37:15 +0200 Subject: [PATCH 15/30] Handle account switch in RootFlowNode instead of in presenters PreferencesRootPresenter and HomePresenter were updating the latest session in the session store directly, relying on RootNavStateFlow to trigger the navigation as a side effect. Changing the current session is app-level navigation, so it is now forwarded through a new `navigateToSession` callback up to RootFlowNode, which performs the session store update. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../appnav/LoggedInAppScopeFlowNode.kt | 6 +++ .../android/appnav/LoggedInFlowNode.kt | 10 +++++ .../io/element/android/appnav/RootFlowNode.kt | 6 +++ .../features/home/api/HomeEntryPoint.kt | 2 + .../android/features/home/impl/HomeEvent.kt | 3 -- .../features/home/impl/HomeFlowNode.kt | 1 + .../features/home/impl/HomePresenter.kt | 3 -- .../android/features/home/impl/HomeView.kt | 10 +++-- .../home/impl/DefaultHomeEntryPointTest.kt | 2 + .../home/impl/roomlist/RoomListViewTest.kt | 1 + .../preferences/api/PreferencesEntryPoint.kt | 2 + .../preferences/impl/PreferencesFlowNode.kt | 5 +++ .../impl/root/PreferencesRootEvent.kt | 3 -- .../impl/root/PreferencesRootNode.kt | 3 ++ .../impl/root/PreferencesRootPresenter.kt | 3 -- .../impl/root/PreferencesRootView.kt | 7 +++- .../impl/DefaultPreferencesEntryPointTest.kt | 2 + .../impl/root/PreferencesRootPresenterTest.kt | 20 ---------- .../impl/root/PreferencesRootViewTest.kt | 37 +++++++++++-------- 19 files changed, 74 insertions(+), 52 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index ee8398186d..90aaeadefc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize @@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } return createNode(buildContext, listOf(callback)) } 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 188346f12a..27d727afa9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -85,6 +85,7 @@ import io.element.android.libraries.matrix.api.MatrixClient 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.RoomIdOrAlias +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.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -169,6 +170,7 @@ class LoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -356,6 +358,10 @@ class LoggedInFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } homeEntryPoint.createNode( parentNode = this, @@ -448,6 +454,10 @@ class LoggedInFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToLinkNewDevice() { backstack.push(NavTarget.LinkNewDevice) } 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 673bdc1388..5fb7996a0e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -416,6 +416,12 @@ class RootFlowNode( override fun navigateToAddAccount() { backstack.push(NavTarget.NotLoggedInFlow(null)) } + + override fun navigateToSession(sessionId: SessionId) { + lifecycleScope.launch { + sessionStore.setLatestSession(sessionId.value) + } + } } val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val buildContext = if (savedNavState != null) { diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 486a8bafce..81c467246f 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint 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.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 6d1eec3dff..abb0b1b837 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -8,10 +8,7 @@ package io.element.android.features.home.impl -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent - data class SwitchToAccount(val sessionId: SessionId) : HomeEvent data object DismissMultiAccountAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index a481171e97..fda28e0fa0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -222,6 +222,7 @@ class HomeFlowNode( onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToAccount = callback::navigateToSession, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95f6c229b8..1938e714eb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -126,9 +126,6 @@ class HomePresenter( is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } - is HomeEvent.SwitchToAccount -> coroutineState.launch { - sessionStore.setLatestSession(event.sessionId.value) - } HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { announcementService.onAnnouncementDismissed(Announcement.MultiAccount) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index c32ed981c0..f9e5809ed5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState 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.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -78,6 +79,7 @@ fun HomeView( onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToAccount: (SessionId) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -130,6 +132,7 @@ fun HomeView( onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, + onSwitchToAccount = onSwitchToAccount, ) if (state.globalSearchState.isEnabled) { @@ -167,6 +170,7 @@ private fun HomeScaffold( onStartChatClick: () -> Unit, onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, + onSwitchToAccount: (SessionId) -> Unit, modifier: Modifier = Modifier, ) { fun onRoomClick(room: RoomListRoomSummary) { @@ -213,9 +217,7 @@ private fun HomeScaffold( }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, - onAccountSwitch = { - state.eventSink(HomeEvent.SwitchToAccount(it)) - }, + onAccountSwitch = onSwitchToAccount, scrollBehavior = scrollBehavior, displayFilters = state.displayRoomListFilters, filtersState = roomListState.filtersState, @@ -379,6 +381,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta onRoomClick = { _, _ -> }, onSettingsClick = {}, onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -400,6 +403,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { onRoomClick = { _, _ -> }, onSettingsClick = {}, onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 643de32ecb..6e962f6fe0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint 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.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.services.analytics.test.FakeAnalyticsService @@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 3cff7ca28b..c6d185550c 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -305,6 +305,7 @@ private fun AndroidComposeUiTest.setRoomListView( onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, onAddAccountClick = onAddAccountClick, + onSwitchToAccount = EnsureNeverCalledWithParam(), onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index e7fbe6069f..a54fc98438 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { @@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToLinkNewDevice() fun navigateToBugReport() fun navigateToSecureBackup() diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index d9db0219d4..a49053546d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.SessionScope 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.user.MatrixUser import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint @@ -141,6 +142,10 @@ class PreferencesFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToAnalyticsSettings() { backstack.push(NavTarget.AnalyticsSettings) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index be2c873a3b..6498c2464b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -8,11 +8,8 @@ package io.element.android.features.preferences.impl.root -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface PreferencesRootEvent { data object OnVersionInfoClick : PreferencesRootEvent - data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent data object ToggleOtherAccountsExpanded : PreferencesRootEvent data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index c4416ad61e..93d6d25cbd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId @ContributesNode(SessionScope::class) @AssistedInject @@ -35,6 +36,7 @@ class PreferencesRootNode( ) : Node(buildContext, plugins = plugins) { interface Callback : PreferencesAccountCallback, Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToAnalyticsSettings() fun navigateToAbout() fun navigateToDeveloperSettings() @@ -72,6 +74,7 @@ class PreferencesRootNode( modifier = modifier, onBackClick = this::navigateUp, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToSession = callback::navigateToSession, onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAbout = callback::navigateToAbout, onOpenDeveloperSettings = callback::navigateToDeveloperSettings, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 2bd55397c5..7d12fd8f51 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -124,9 +124,6 @@ class PreferencesRootPresenter( is PreferencesRootEvent.OnVersionInfoClick -> { showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } - is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { - sessionStore.setLatestSession(event.sessionId.value) - } PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch { appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 38c02f04e1..a509dbfbd0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -65,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -79,6 +80,7 @@ fun PreferencesRootView( emojiPickerRenderer: EmojiPickerRenderer, onBackClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, onOpenAnalytics: () -> Unit, onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, @@ -124,6 +126,7 @@ fun PreferencesRootView( MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, ) } HorizontalDivider( @@ -208,6 +211,7 @@ private fun ColumnScope.UserStatusSection( private fun ColumnScope.MultiAccountSection( state: PreferencesRootState, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, ) { HorizontalDivider( thickness = 8.dp, @@ -275,7 +279,7 @@ private fun ColumnScope.MultiAccountSection( MatrixUserRow( modifier = Modifier .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + onSwitchToSession(matrixUser.userId) } .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), matrixUser = matrixUser, @@ -427,6 +431,7 @@ private fun ContentToPreview(state: PreferencesRootState) { emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = {}, onAddAccountClick = {}, + onSwitchToSession = {}, onOpenAnalytics = {}, onOpenDeveloperSettings = {}, onOpenLocationSettings = {}, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index afc3801c88..80490255a6 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint 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.mediaviewer.test.FakeFileViewerEntryPoint import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint @@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest { } val callback = object : PreferencesEntryPoint.Callback { override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToSecureBackup() = lambdaError() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 34f60cbae3..45a0f577eb 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -39,8 +39,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -179,24 +177,6 @@ class PreferencesRootPresenterTest { } } - @Test - fun `present - switch session invoke method on the session store`() = runTest { - val setLatestSessionResult = lambdaRecorder { } - val sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData(sessionId = A_SESSION_ID.value), - aSessionData(sessionId = A_SESSION_ID_2.value), - ), - setLatestSessionResult = setLatestSessionResult, - ) - createPreferencesRootPresenter(sessionStore = sessionStore).test { - val loadedState = awaitFirstItem() - loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) - setLatestSessionResult.assertions().isCalledOnce() - .with(value(A_SESSION_ID_2.value)) - } - } - @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { createPreferencesRootPresenter( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 3afd6cde9d..6b5ba45109 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -69,23 +70,25 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setView( - aPreferencesRootState( - isMultiAccountEnabled = true, - isOtherAccountsSectionExpanded = true, - otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", - ) + fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(A_USER_ID_2) { callback -> + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, ), - eventSink = eventsRecorder, - ), - ) - onNodeWithText("Bob").performClick() - eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) + onSwitchToSession = callback, + ) + onNodeWithText("Bob").performClick() + } } @Test @@ -395,6 +398,7 @@ private fun AndroidComposeUiTest.setView( state: PreferencesRootState, onBackClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(), + onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(), onOpenAnalytics: () -> Unit = EnsureNeverCalled(), onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), onOpenAbout: () -> Unit = EnsureNeverCalled(), @@ -418,6 +422,7 @@ private fun AndroidComposeUiTest.setView( emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = onBackClick, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, onOpenAnalytics = onOpenAnalytics, onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, From 8a5c50d2b8a5f6a2475b314eaeaebe3762c0b81b Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 11:13:14 +0200 Subject: [PATCH 16/30] Fix `File.getSizeOfFiles()` double counting some files (#7907) The `File.length()` docs state: > Returns the length of the file denoted by this abstract pathname. The return value is unspecified if this pathname denotes a directory. On Android, it *seems* to currently have the right behaviour, summing up the size of its children, which made `File.getSizeOfFiles()` double-count the size of some directories. To avoid this, and be able to control the actual behaviour no matter what future Android versions do, let's count those ourselves. --- .../io/element/android/libraries/androidutils/file/File.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 7c5f0450e9..9023a8f935 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long { Timber.v("Get size of ${it.absolutePath}") true } - .sumOf { it.length() } + // Only sum the actual size of files, not directories, to avoid double counting or worse, + // since the docs mention that `length()` for directories is not reliable. + // For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder, + // which is a common block size on many file systems. + .sumOf { if (it.isFile) it.length() else 4096L } } From 241f8e67bd52505765ac0e8036c2b0159b2c802e Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 11:13:59 +0200 Subject: [PATCH 17/30] Fix side-effects when tapping on a link in the new renderer (#7906) The case where we move the pointer once we've started the click on a link wasn't taken into account, so the click or long click gestures weren't canceled if the gesture ended up being a 'move' one. Simplify the handler and make it more idiomatic too. --- .../htmlrenderer/api/ui/HtmlMessageContent.kt | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index e779b5086f..283aa356be 100644 --- a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -53,8 +55,7 @@ import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException -import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard @@ -565,34 +566,18 @@ private fun Modifier.linkTapHandler( val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture down.consume() - val longPressTimeout = viewConfiguration.longPressTimeoutMillis - - // Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that. - // If we did, it's a tap, otherwise it's a long press. - // This warning is a false positive. - @Suppress("KotlinConstantConditions") - val isLongPress = try { - withTimeout(longPressTimeout) { - var receivedUp = false - while (!receivedUp) { - val event = awaitPointerEvent() - val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() } - if (up != null) { - receivedUp = true - up.consume() - } - } - - !receivedUp - } - } catch (_: PointerEventTimeoutCancellationException) { - true - } - - if (isLongPress) { + // Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event. + if (awaitLongPressOrCancellation(down.id) != null) { context.onLinkLongClick(url, urlText) + // Swallow the rest of the gesture so parents don't react to the eventual release. + waitForUpOrCancellation()?.consume() } else { - context.onLinkClick(url, urlText) + // Either a tap (the pointer was released) or a drag (the user probably wanted to scroll). + val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() } + if (up != null) { + up.consume() + context.onLinkClick(url, urlText) + } } } } From 566eb1f4f3af97526359beb85f7414e0402e0c71 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 11:14:38 +0200 Subject: [PATCH 18/30] Use local SDK library for EC native too (#7916) When a local SDK AAR file is present, ignore the version needed by the Element Call Matrix library. This allows us to test local SDK builds. --- libraries/matrix/impl/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 9d025fc9a8..91b0b9c5c3 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -59,7 +59,11 @@ dependencies { implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) implementation(libs.element.call.api) implementation(projects.features.callnative.api) - implementation(libs.element.call.matrix) + implementation(libs.element.call.matrix) { + if (usesLocalRustSdk) { + exclude(group = "org.matrix.rustcomponents", module = "sdk-android") + } + } implementation(projects.appconfig) implementation(projects.features.enterprise.api) From f72af72d802f9747b066b8ed87bf339f68bb23e9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 11:35:13 +0200 Subject: [PATCH 19/30] Add a back button to the account provider confirmation screen The screen where the user enters the server address only supported the system back gesture. Add a top app bar with a back button, consistent with the screen to choose the account provider and FlowStepPage, so the user can navigate back from it, especially when adding another account. Part of #7768 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ConfirmAccountProviderNode.kt | 1 + ...ConfirmAccountProviderStatePreviewParam.kt | 2 +- .../ConfirmAccountProviderView.kt | 13 ++++- .../ConfirmAccountProviderViewTest.kt | 57 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index e1a2d85412..e60358c7f5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -56,6 +56,7 @@ class ConfirmAccountProviderNode( ConfirmAccountProviderView( state = state, modifier = modifier, + onBackClick = ::navigateUp, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt index be85a306ba..4657f4c1d7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt @@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider Unit, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, @@ -118,9 +122,15 @@ fun ConfirmAccountProviderView( // Let the header and content scroll so the supporting text is not clipped by the footer when the // keyboard is visible and vertical space is reduced. isScrollable = true, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, header = { IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 60.dp), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { @@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview( ) = ElementPreview { ConfirmAccountProviderView( state = state, + onBackClick = {}, onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt new file mode 100644 index 0000000000..61f22a1fec --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt @@ -0,0 +1,57 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.matrix.api.auth.OAuthDetails +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class ConfirmAccountProviderViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setConfirmAccountProviderView( + state = aConfirmAccountProviderState( + eventSink = eventSink, + ), + onBackClick = it, + ) + pressBack() + } + } + + private fun AndroidComposeUiTest.setConfirmAccountProviderView( + state: ConfirmAccountProviderState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(), + onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ConfirmAccountProviderView( + state = state, + onBackClick = onBackClick, + onOAuthDetails = onOAuthDetails, + onNeedLoginPassword = onNeedLoginPassword, + onLearnMoreClick = onLearnMoreClick, + ) + } + } +} From 6a516a02aeca03779df7864850f48b70a2e0dc24 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 11:56:28 +0200 Subject: [PATCH 20/30] Iterate on the identity confirmation screen Follow the multi-account design for the "Confirm your digital identity" screen: use the dedicated "Sign out" string for the top bar action and add a leading icon to the "Use another device" button. Add a test to check that clicking on "Sign out" emits the expected event. Part of #7768 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ChooseSelfVerificationModeStatePreviewParam.kt | 3 ++- .../choosemode/ChooseSelfVerificationModeView.kt | 7 ++++++- features/ftue/impl/src/main/res/values/localazy.xml | 1 + .../ChooseSessionVerificationModeViewTest.kt | 11 +++++++++++ .../impl/src/main/res/values/localazy.xml | 1 + 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt index d12a3003ae..34125cb50b 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt @@ -46,11 +46,12 @@ class ChooseSelfVerificationModeStatePreviewParam : fun aChooseSelfVerificationModeState( buttonsState: AsyncData = AsyncData.Success(aButtonsState()), currentUser: MatrixUser = aMatrixUser(), + eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {}, ) = ChooseSelfVerificationModeState( currentUser = currentUser, buttonsState = buttonsState, directLogoutState = aDirectLogoutState(), - eventSink = {}, + eventSink = eventSink, ) fun aButtonsState( diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index 63d653410f..d58f83200c 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @@ -45,6 +46,9 @@ import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfo import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813 + */ @Composable fun ChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, @@ -65,7 +69,7 @@ fun ChooseSelfVerificationModeView( title = {}, actions = { TextButton( - text = stringResource(CommonStrings.action_signout), + text = stringResource(R.string.screen_identity_confirmation_sign_out), onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) } ) } @@ -141,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.screen_identity_use_another_device), onClick = onUseAnotherDevice, + leadingIcon = IconSource.Vector(CompoundIcons.Devices()), ) } if (state.buttonsState.data.canUseRecoveryKey) { diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 8e208278a0..6cd117eb59 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt index 66e2eb38fe..419c8d3371 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() { } } + @Test + fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(eventSink = eventsRecorder), + ) + clickOn(R.string.screen_identity_confirmation_sign_out) + eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut) + } + private fun AndroidComposeUiTest.setChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, onLearnMoreClick: () -> Unit = EnsureNeverCalled(), diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index 012437792e..5803cc2a95 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" From 933fc54e12c40d9a8a72aaf38bb9ee8968f5cfb4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 12:03:14 +0200 Subject: [PATCH 21/30] Iterate on add account onboarding UI. --- .../login/impl/screens/onboarding/OnBoardingView.kt | 8 ++++++-- features/login/impl/src/main/res/values/localazy.xml | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 018d55ab09..59d3863b4f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold( ) } +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607 + */ @Composable private fun AddOtherAccountScaffold( loginView: @Composable () -> Unit, @@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold( ) { FlowStepPage( modifier = modifier, - title = stringResource(CommonStrings.common_add_account), - iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + title = stringResource(R.string.screen_onboarding_add_account_title), + subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()), buttons = { buttons() }, content = loginView, onBackClick = onBackClick, diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index d77081a70f..0d716537f3 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -52,6 +52,8 @@ "Follow the instructions to enable your key storage" "Come back to %1$s" "Enable your key storage before proceeding to %1$s" + "You can add multiple accounts to the app and switch between them easily." + "Sign in to another account" "Version %1$s" "Checking account" "Sign in manually" From be98d04f4090fa3cb64ac887d9a24f8427fc3f90 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 14 Oct 2026 10:26:25 +0000 Subject: [PATCH 22/30] Update screenshots --- ...ion.choosemode_ChooseSelfVerificationModeView_Day_0_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_1_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_2_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_3_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_4_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_0_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_1_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_2_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_3_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_4_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_0_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_1_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_2_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_3_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_0_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_1_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_2_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_3_en.png | 4 ++-- ....login.impl.screens.onboarding_OnBoardingView_Day_7_en.png | 4 ++-- ...ogin.impl.screens.onboarding_OnBoardingView_Night_7_en.png | 4 ++-- 20 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png index 12adbd61f4..a2fb238031 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6daccb2b17eca0c0720ae731f38f82710839d5c744c9d1a8d60b70e6da41c82a -size 39472 +oid sha256:54394615b9621785f54b1974802f9c2a4808f07abb5f4e4c32a390421e513af3 +size 37567 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png index 8e421af5ee..f34eec28bf 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25b6ab1e24e67a122eebf2e02d48412a349b85b9b8f2e742b7f1bca5c1146175 -size 34664 +oid sha256:93b596bdc0e7f228709ac9d06f6e183ca398ea55b3d08d95d24f0f5fa7a42be5 +size 32704 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png index 7be0af4b93..460c026aae 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbb89e5933828be05e39fa7bcebee6eeb34459913e4b999103fd04190184e405 -size 44172 +oid sha256:e8ba95c046cbd87f77a575f4b05e6f8dfe456a3072cea0f7b9d6318302555696 +size 42473 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png index 36a948c665..22ab1da7d7 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb092bee675333a26b9f2a63bc947c029356808caa65a1f77b317f3b42dc4b49 -size 39596 +oid sha256:ed2510e7bbbd746381d3c6c031adfeef970b4b7002b80ea642df4aa106068aa6 +size 37891 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png index 2052266989..d74e427dd2 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:877604b659b8fa76febe1afb2509bc75d61d665b5f7fdc4bfc863db5e18edcb4 -size 33366 +oid sha256:b436b82845778365f2f419ca4a0da2b856ca7f424153bda1e1904bc675906ea2 +size 31387 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png index e3957acf4d..c2d0e7137d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:819c5f83d31fe66e64662e0f78f73b46ec0405ac3ef0058e688bc9bda09abfc2 -size 38430 +oid sha256:2c313aba8c31160b759102755059257f082b98abe72ec138fdbdb3c9bde1409c +size 36566 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png index a49f6804b1..6197904ce6 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9079a774c1acb8d925591bd060be5f90e7f7176ada04163507fcaeb508697c8e -size 33802 +oid sha256:3010901ea3daddd0022ebce001f236f1b76946edf922de64c8fb10e3e63e043e +size 31941 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png index ce925ec765..f1c966131d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c14e2c3308774dd97e5d8e793c53836925d6bd61e27dfe6a0192f6fe2514086c -size 42902 +oid sha256:4d84fb04e7c4444fdaa942d4ebda166f8b76dcf5fdf14ebc8ff0eabdbbb6d850 +size 41302 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png index 4dd0b016ae..a9e7ae415f 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29e50d5d09399a001a88199add0a5ff6974d546d16a127aa699862dd1535de59 -size 38457 +oid sha256:d357c719b4b315da87352e676d9d0d5affff06eff29f8409e2f31dbe76bfec66 +size 36816 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png index 8e90d03e64..6d18750ccb 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:679496d2ce865f83726406b3c3e7c831e48b08e3e42972eb608a5c339319179e -size 32459 +oid sha256:7a9578d6356ee7aab713fc67978aa0a5d74cba5f5094d9f589dc05598925a990 +size 30578 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png index 6037f8376f..3400dba011 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1658bb9275f540a293117d7e387948d0fe2b8ed8c3c61bdba94bf4229bc4403b -size 35906 +oid sha256:366574d116e3fa7a822bcb1e3fb73dadcd4830c8b065c59c020a0c87e4f0b43d +size 35927 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png index f5631fc5b4..7a4b958eab 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5df9a053734bd8e175d28174e9ccb14cdd8403322c727b791b7323adcf1b711e -size 35786 +oid sha256:983e3b175aef6a5ed53609634dc9c8734cee1193fc20f34ed4bd82d78e518de6 +size 35892 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png index f1a3b38270..54a8fa84d4 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73ef55cee2a287ba1ba9f46c0cd902724cd42ac88a36c63195b4d245bd5ac061 -size 39639 +oid sha256:f0bccfc71f3fe376b587128826aa11bba87afa1af2d0736469bcc048a1315607 +size 39566 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png index 1e6c5df990..d709499232 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecb9ad5d977a95e30dde4bc29352f692fda9f73406785dd9a578906173219460 -size 32518 +oid sha256:703e28ef45cba713b32a65ed73ccd594738bcee7121447b1d4521605e2bfb77d +size 32968 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png index 5ce7683510..283e1d3e71 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a46bc1ee0e63d6785a0c9b4ff739f5694432de9cd49c7db6884bf19eceac545 -size 35040 +oid sha256:283d80c3d4656ab9c9137cd3a5c2730d73850ce57711a464cdb54f8d6f070820 +size 35084 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png index 5a99714595..dacc3ee7b8 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e1119b78f27ff7c7eac03d7742edf22f73c9e5a7f9c5c8b467c2f2e823da09e -size 34934 +oid sha256:edc5f89c33428fdb6a6fc33d479f9e791dda0554c623360399458d4d2b9521d4 +size 35058 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png index 52597c381d..82b0c9f339 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c9489d83c2ef0f6aeacff1926594ca17784481fa542daa16bbe6bf2749c2122 -size 38823 +oid sha256:2c00a26b76909b5863278c3d9ac001b34630dae9ba39ab47a01c3ec8a3b7e799 +size 38707 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png index e4455a669a..df2d65517d 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0cc47f7a7008742d0239cb3a2e239c38d6a2eb581d49c0cd3f99f0a71f5c9ff -size 30670 +oid sha256:2bdb861840006eb27eedf85b0f344624dc611983d8c91310124d29be0c6e5f40 +size 30827 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png index ff3ef1f7f6..2e4348101d 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e70863623c70a3aed1fc947dfb26f19bc31abf563e793f97cf02f1f15e12bb0 -size 21797 +oid sha256:25447835e040327d9f7e257ab5488912ca140e44e55c43201490955c50a17d37 +size 33651 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png index 71e0095992..c1f37d1503 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76120a9ba1378b8ac2c8963157a6c3d3124114bfef1c5dbac200f7d68bf730db -size 20816 +oid sha256:05eddb1c42f5b7ec5b8dcae7512e17d2ef93f82e6736ec1513df9fe731692435 +size 32379 From 3ef59a891059ca1c9140bbc713328169951bbc94 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 14:04:07 +0200 Subject: [PATCH 23/30] Don't log OkHttp binary bodies in debug builds (#7919) This is mostly done when uploading bug reports and on one hand, it doesn't really make sense since the binary contents will be unreadable anyway, and on the other it also messes up with our debug tools, making debugging some issues harder. --- .../network/interceptors/DynamicHttpLoggingInterceptor.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt index daaf922ce7..025bf606f5 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.network.interceptors import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.flow.first @@ -31,7 +32,12 @@ class DynamicHttpLoggingInterceptor( override fun intercept(chain: Interceptor.Chain): Response { // This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() } - loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE + // If the request body is binary, we log only the headers to avoid logging binary data + val isBinaryBody = chain.request().headers["Content-Type"] == MimeTypes.OctetStream + loggingInterceptor.level = when { + logLevel >= LogLevel.DEBUG -> if (isBinaryBody) Level.HEADERS else Level.BODY + else -> Level.NONE + } return loggingInterceptor.intercept(chain) } } From 1c500374c168734fc1da066664a66257904900fc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:25:19 +0000 Subject: [PATCH 24/30] Update actions/upload-artifact action to v7.0.2 (#7921) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/build_enterprise.yml | 2 +- .github/workflows/maestro-local.yml | 4 ++-- .github/workflows/nightlyReports.yml | 4 ++-- .github/workflows/quality.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e997e1cbdd..b1ba9dd010 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-debug path: | diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index 294b94a872..589f0a329d 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -79,7 +79,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug Enterprise APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-enterprise-debug path: | diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 89413a4d2d..25aa0f6eac 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -63,7 +63,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - name: Upload APK as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-apk-maestro path: | @@ -138,7 +138,7 @@ jobs: pip install requests python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement - name: Upload test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-results path: | diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 88b267acc4..ad55afda66 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -58,7 +58,7 @@ jobs: - name: ✅ Upload kover report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-results path: | @@ -92,7 +92,7 @@ jobs: run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES - name: Upload dependency analysis if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dependency-analysis path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 006b0101f6..2efd222fc7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -120,7 +120,7 @@ jobs: run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: konsist-report path: | @@ -199,7 +199,7 @@ jobs: run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: linting-report path: | @@ -240,7 +240,7 @@ jobs: run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: detekt-report path: | @@ -281,7 +281,7 @@ jobs: run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ktlint-report path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd1bba77f4..316fcc27ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ jobs: ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-gplay-bundle-unsigned path: | @@ -105,7 +105,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload apks as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-fdroid-apks-unsigned path: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b54741c566..816bf1e441 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,7 +83,7 @@ jobs: - name: 🚫 Upload kover failed coverage reports if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-error-report path: | @@ -95,7 +95,7 @@ jobs: - name: 🚫 Upload test results on error if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: tests-and-screenshot-tests-results path: | From 2bec8e1487260e8818be533be76f19fafae0befb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 14:46:05 +0200 Subject: [PATCH 25/30] Let Gradle continue after a task failure in CI checks Add --continue to the unit tests, screenshot tests, detekt and ktlint Gradle invocations, so that a failure in one module does not prevent the other modules from being checked, and the CI reports all the failures at once. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/nightlyReports.yml | 4 ++-- .github/workflows/quality.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index ad55afda66..5c3e33f577 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -48,10 +48,10 @@ jobs: cache-read-only: false - name: ⚙️ Run unit tests, debug and release - run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue - name: 📸 Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: 📈 Generate kover report and verify coverage run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2efd222fc7..1fa443c0f5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -237,7 +237,7 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt - run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon + run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue - name: Upload reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -278,7 +278,7 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check - run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 816bf1e441..6222ac024c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,10 +73,10 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: ⚙️ Run unit tests - run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Generate kover coverage report run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES From faa7956b93e3e055247c600b90ada971160af9df Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 14:46:15 +0200 Subject: [PATCH 26/30] Fix flaky NotificationSettingsPresenterTest after loading distributors off the main thread The distributors are now loaded on the IO dispatcher, which was a StandardTestDispatcher in the test, so the initial state could be read before they were loaded. Use an unconfined test dispatcher so that they are loaded deterministically. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/notifications/NotificationSettingsPresenterTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index e37d39d775..ed65f9a9f3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -1177,7 +1177,7 @@ class NotificationSettingsPresenterTest { stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, featureFlagService = featureFlagService, - dispatchers = testCoroutineDispatchers(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ) } } From 5a5d631a8d30c04cdbc093f4fe6a871e48c54aa1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 17:04:32 +0200 Subject: [PATCH 27/30] Animate all the banner changes of the room list in a single slot Replace DismissibleBanner, which used one AnimatedVisibility per banner, by RoomListBannerSlot, which renders the banner to display with a single AnimatedContent. All the changes are now animated: dismissing a banner, replacing a banner by the next one, and displaying a new banner. Previously, when a banner was dismissed, the next banner was displayed without animation. The dismiss event is now sent immediately, AnimatedContent keeps rendering the dismissed banner while it animates out. The banner slot of the LazyColumn now has a min height of 1px. When it was 0px high, the LazyColumn was considering it as not visible and moved the first visible item to the first room, so a new banner was added above the visible area of the list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../home/impl/components/DismissibleBanner.kt | 78 ------- .../impl/components/RoomListContentView.kt | 217 ++++++++++-------- .../home/impl/roomlist/RoomListState.kt | 33 +++ .../home/impl/roomlist/RoomListViewTest.kt | 6 - 4 files changed, 153 insertions(+), 181 deletions(-) delete mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt deleted file mode 100644 index 1c743ec8dd..0000000000 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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.home.impl.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import kotlinx.coroutines.flow.first - -/** - * Wraps a banner of the room list so that it shrinks and fades out when it is dismissed. - * [onDismiss] is invoked once the animation is over, so that the banner is removed from the list - * when its height is 0, and the items below it are not moved abruptly. - * - * @param onDismiss invoked when the banner has been dismissed and the animation is over. - * @param modifier the modifier to apply to the banner container. - * @param content the banner, which must invoke the provided lambda when the user dismisses it. - */ -@Composable -internal fun DismissibleBanner( - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable (onDismissClick: () -> Unit) -> Unit, -) { - val visibleState = remember { MutableTransitionState(true) } - val latestOnDismiss by rememberUpdatedState(onDismiss) - var isDismissNotified by remember { mutableStateOf(false) } - fun notifyDismiss() { - if (!isDismissNotified) { - isDismissNotified = true - latestOnDismiss() - } - } - LaunchedEffect(visibleState) { - snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } - notifyDismiss() - } - DisposableEffect(visibleState) { - onDispose { - // The item can be disposed during the animation, for instance if the user scrolls the list - if (!visibleState.targetState) { - notifyDismiss() - } - } - } - AnimatedVisibility( - visibleState = visibleState, - modifier = modifier, - enter = EnterTransition.None, - exit = shrinkVertically( - animationSpec = tween(), - shrinkTowards = Alignment.CenterVertically, - ) + fadeOut( - animationSpec = tween(), - ), - ) { - content { visibleState.targetState = false } - } -} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 09dc51d68f..02288eb8b7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -8,6 +8,12 @@ package io.element.android.features.home.impl.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,7 +21,9 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter @@ -39,10 +48,11 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.features.home.impl.roomlist.RoomListBanner import io.element.android.features.home.impl.roomlist.RoomListContentState import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam import io.element.android.features.home.impl.roomlist.RoomListEvent -import io.element.android.features.home.impl.roomlist.SecurityBannerState +import io.element.android.features.home.impl.roomlist.toRoomListBanner import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState import io.element.android.libraries.designsystem.preview.ElementPreview @@ -160,30 +170,20 @@ private fun EmptyView( }, modifier = Modifier.align(Alignment.Center), ) - Box { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = onDismissClick, - ) + RoomListBannerContainer( + banner = state.securityBannerState.toRoomListBanner(), + ) { banner -> + RoomListBannerView( + banner = banner, + onActionClick = { + when (banner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + else -> Unit } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = onDismissClick, - ) - } - } - SecurityBannerState.None -> Unit - } + }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, + ) } } } @@ -237,6 +237,10 @@ private fun RoomsViewList( lazyListState: LazyListState, modifier: Modifier = Modifier, ) { + val banner = state.toRoomListBanner() + // The banner slot must never be 0px high, else the LazyColumn considers that it is not visible and it is + // removed from the composition. A new banner would then be added above the visible area of the list. + val bannerSlotMinHeight = with(LocalDensity.current) { 1.toDp() } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> // The first item of the list is the banner slot, remove it to get the range of the rooms val roomsRange = (visibleRange.first - 1).coerceAtLeast(0).. + RoomListBannerView( + banner = displayedBanner, + onActionClick = { + when (displayedBanner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + RoomListBanner.FullScreenIntentPermission -> + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) + RoomListBanner.BatteryOptimization -> + state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) + RoomListBanner.NewNotificationSound, + RoomListBanner.SwipeAvatar -> Unit + } + }, + onDismissClick = { + when (displayedBanner) { + RoomListBanner.SetUpRecovery, + RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner) + RoomListBanner.FullScreenIntentPermission -> + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) + RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) + RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner) + RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner) + } + }, + ) + } } // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // is moved to the top of the list. @@ -281,79 +310,73 @@ private fun RoomsViewList( } } +/** + * Displays the [banner] at the top of the room list, and animates every change: when the banner is + * dismissed, when it is replaced by another banner, and when a banner appears. + * The size of the slot is animated, so that the items below it are not moved abruptly. + * + * @param banner the banner to display, or null if there is no banner to display. + * @param modifier the modifier to apply to the slot. + * @param content renders the given banner. + */ @Composable -private fun RoomsViewBanner( - state: RoomListContentState.Rooms, - eventSink: (RoomListEvent) -> Unit, - onSetUpRecoveryClick: () -> Unit, - onConfirmRecoveryKeyClick: () -> Unit, +private fun RoomListBannerContainer( + banner: RoomListBanner?, + modifier: Modifier = Modifier, + content: @Composable (RoomListBanner) -> Unit, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = onDismissClick, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = onDismissClick, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - DismissibleBanner( - onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - ) { onDismissClick -> - FullScreenIntentPermissionBanner( - onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, - onDismissClick = onDismissClick, - ) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - DismissibleBanner( - onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) }, - ) { onDismissClick -> - BatteryOptimizationBanner( - onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = onDismissClick, - ) - } - } - state.showNewNotificationSoundBanner -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) { onDismissClick -> - NewNotificationSoundBanner( - onDismissClick = onDismissClick, - ) - } - } - state.showSwipeAvatarBanner -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) { onDismissClick -> - SwipeAvatarBanner( - onDismissClick = onDismissClick, - ) - } - } + AnimatedContent( + targetState = banner, + // Fill the width so that only the height is animated + modifier = modifier.fillMaxWidth(), + transitionSpec = { + fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith + fadeOut(tween(durationMillis = HALF_DURATION_MILLIS)) using + SizeTransform { _, _ -> tween(durationMillis = 2 * HALF_DURATION_MILLIS) } + }, + contentAlignment = Alignment.Center, + label = "RoomListBannerContainer", + ) { targetBanner -> + if (targetBanner != null) { + content(targetBanner) } } } +private const val HALF_DURATION_MILLIS = 150 + +@Composable +private fun RoomListBannerView( + banner: RoomListBanner, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, +) { + when (banner) { + RoomListBanner.SetUpRecovery -> SetUpRecoveryKeyBanner( + onContinueClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner( + onContinueClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner( + onActionClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner( + onActionClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner( + onDismissClick = onDismissClick, + ) + RoomListBanner.SwipeAvatar -> SwipeAvatarBanner( + onDismissClick = onDismissClick, + ) + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index 6d3d85ab56..f77695399b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -61,6 +61,18 @@ enum class SecurityBannerState { RecoveryKeyConfirmation, } +/** + * The banners which can be displayed at the top of the room list. At most one banner is displayed at a time. + */ +internal enum class RoomListBanner { + SetUpRecovery, + RecoveryKeyConfirmation, + FullScreenIntentPermission, + BatteryOptimization, + NewNotificationSound, + SwipeAvatar, +} + @Immutable sealed interface RoomListContentState { data class Skeleton(val count: Int) : RoomListContentState @@ -80,3 +92,24 @@ sealed interface RoomListContentState { val showAllActivity: Boolean, ) : RoomListContentState } + +internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) { + SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery + SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation + SecurityBannerState.None -> null +} + +/** + * Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization > + * new-notification-sound > swipe-avatar. At most one renders at a time. + */ +internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? { + return securityBannerState.toRoomListBanner() ?: when { + fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission + batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization + showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound + showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar + else -> null + } +} + diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index e93aef71f5..eb41331202 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -76,8 +76,6 @@ class RoomListViewTest : RobolectricTest() { val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -98,8 +96,6 @@ class RoomListViewTest : RobolectricTest() { val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -121,8 +117,6 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertEmpty() val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) } From e186e62a0ea2d652d3dfaf313f9948ecdf0f0479 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 17:41:07 +0200 Subject: [PATCH 28/30] Move RoomListBanner to its own file The test source set also has a RoomListState.kt file in the same package, so its RoomListStateKt class was shadowing the one of the main source set, and the toRoomListBanner() functions were not found when running the tests (NoSuchMethodError). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../home/impl/roomlist/RoomListBanner.kt | 40 +++++++++++++++++++ .../home/impl/roomlist/RoomListState.kt | 33 --------------- 2 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt new file mode 100644 index 0000000000..c273cc9ce4 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt @@ -0,0 +1,40 @@ +/* + * 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.home.impl.roomlist + +/** + * The banners which can be displayed at the top of the room list. At most one banner is displayed at a time. + */ +internal enum class RoomListBanner { + SetUpRecovery, + RecoveryKeyConfirmation, + FullScreenIntentPermission, + BatteryOptimization, + NewNotificationSound, + SwipeAvatar, +} + +internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) { + SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery + SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation + SecurityBannerState.None -> null +} + +/** + * Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization > + * new-notification-sound > swipe-avatar. At most one renders at a time. + */ +internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? { + return securityBannerState.toRoomListBanner() ?: when { + fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission + batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization + showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound + showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar + else -> null + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index f77695399b..6d3d85ab56 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -61,18 +61,6 @@ enum class SecurityBannerState { RecoveryKeyConfirmation, } -/** - * The banners which can be displayed at the top of the room list. At most one banner is displayed at a time. - */ -internal enum class RoomListBanner { - SetUpRecovery, - RecoveryKeyConfirmation, - FullScreenIntentPermission, - BatteryOptimization, - NewNotificationSound, - SwipeAvatar, -} - @Immutable sealed interface RoomListContentState { data class Skeleton(val count: Int) : RoomListContentState @@ -92,24 +80,3 @@ sealed interface RoomListContentState { val showAllActivity: Boolean, ) : RoomListContentState } - -internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) { - SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery - SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation - SecurityBannerState.None -> null -} - -/** - * Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization > - * new-notification-sound > swipe-avatar. At most one renders at a time. - */ -internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? { - return securityBannerState.toRoomListBanner() ?: when { - fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission - batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization - showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound - showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar - else -> null - } -} - From c3f178985d0fe305ed8feb85eb33336e2dfd01fd Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 14 Oct 2026 16:00:19 +0000 Subject: [PATCH 29/30] Update screenshots --- ...ures.home.impl.components_RoomListContentView_Day_0_en.png | 4 ++-- ...es.home.impl.components_RoomListContentView_Night_0_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeViewA11y_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_0_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_17_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_1_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_2_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_3_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_5_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_6_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_7_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_8_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_0_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_17_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_1_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_2_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_3_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_5_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_6_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_7_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_8_en.png | 4 ++-- 21 files changed, 42 insertions(+), 42 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png index 8106e587cf..5081cf144a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96a167627e1af6e5db7ecc39efb0d52a2ad3e23f84c2cd1222f088102fd22303 -size 41053 +oid sha256:7965b2152ccf703ca1ce05103835360e5a6c105708ddda2af2f3ebe08311d627 +size 41025 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png index a6ccbb245e..5975364c78 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:875e9afdf2d3b6b67ad2376a788b2ec92fa3099a4b6882ee6834da1ec4dad7de -size 40836 +oid sha256:3f2a815d81ef6d332ca4a76ea82cc20cced9069f82defece60ff7d6ba6c3bdd9 +size 40914 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png index 8534820c63..2adbae0e28 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70793555aa11eede96e92e3d9a9186c75c4056a5f193bb5a3962227208777866 -size 143038 +oid sha256:932bd7fc62a301bd910dec13461b10bbc3a5d8df3dd7047f20ebc2d357b88f31 +size 141308 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png index 342d1ab6ff..a13655bf74 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5c44832e3d4cbb637d48abc19174a66bdff343de3e60aa94c3d6bfaac44e78d -size 62363 +oid sha256:004f6237e1f07aa2a62930b93e9f45d8ebd4abc7b359c974bb487ad586f60357 +size 62250 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png index cfd1827f50..ab727d2957 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6618a3f641c4919fd009f8bab432cd0d03ee933accf5b33815202eed721c78bf -size 65320 +oid sha256:229bdf660691f48123b4d7b0de033032f5450fb24dae2df657672231f7368c7c +size 65167 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png index 516089368e..5b193f20cd 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd658e684c89fc1b201a4a4090037d83c8882774b2a2593b12b30bd011971ecf -size 55324 +oid sha256:c4f07548ec04398f5446d01156decfe01110f41161fb7355207768ab6341c593 +size 55183 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png index f4bb27c8b4..7674a58449 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c5798c49f07cd4a564c6b48c18dc0a67b5437e403da63e154c6bdbcee9fb060 -size 55128 +oid sha256:83e0e84e890b40d4db862ff65689e3234abb205b7aa759bd0c653af47cf0f53e +size 54984 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png index b43498911a..55ff492464 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c8042cfa5d850e0322da8a6cb1fb019da4d3675c469d29aec84fe9d6a8aa147 -size 53368 +oid sha256:c55cff055873cf6301962c164865f728032e08a6ab4c676be84ddddddaadc99e +size 53226 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png index a2a191b30f..453c5f37b7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:513e23929e81026a24cb2d964fd8a65ba4998ac715021d480d7e7211d6a48d0a -size 59148 +oid sha256:54a0b9e36eb0091d7a29d6b88e0e40424259ab04ff3544017ce249367109fd0b +size 58996 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png index a1d6128daa..87b9d4b528 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89e25edf690c8c416699e82781a4dd01f4ab370e8ca868b7bd4ce1a0e5a1124e -size 60532 +oid sha256:0b6c5ebd0eea3715dc7f053ec196223f64226231f41f7a03251784828a0dd876 +size 60371 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png index 14dc407833..f065d6e748 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:784de072b6803569aaba3fddfeb1301ace32f51d2a3b78d823ccaaa77fd5d52e -size 52222 +oid sha256:5b90e992b69d06287ce60300b796da1ac17dddea00922f2e42dfdf63631e8a3e +size 52027 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png index d4f6602825..fc2be0901e 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdec8293d351e9b879bafdff048a9ae3bdeaf34d1e47125fe94386415ef80ac4 -size 51941 +oid sha256:bf551434aa4ea85f250fab838aabb77a41b834f8bfdba5cdcd1263e16e67fb39 +size 51759 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png index 2dcffff9f8..c7e8c0a35f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0599ae5bfbd84c1087b06b1e50352fd21b0d784e62536868f012f8a3ab8da95 -size 50257 +oid sha256:95001ba7ba09516aa2da086e07d3dbf2931698f36c9c8425155204e0f8d0bf2d +size 50058 From 9efe0db5709b1da1d3ceafca00715584104c354f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 17:56:54 +0200 Subject: [PATCH 30/30] Dismiss the swipe avatar announcement in the app coroutine scope HomeView sends HomeEvent.DismissSwipeAvatarAnnouncement right before switching account. Switching account replaces the LoggedInFlow root node, which destroys HomePresenter and cancels its composition coroutine scope, so the dismissal could be cancelled before the announcement status was stored. Use the app coroutine scope instead, which survives the account switch. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/features/home/impl/HomePresenter.kt | 11 ++++++++--- .../android/features/home/impl/HomePresenterTest.kt | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 3f4901d5f2..c250569976 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -28,6 +28,7 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +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.indicator.api.IndicatorService @@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine @@ -56,13 +58,15 @@ class HomePresenter( private val sessionStore: SessionStore, private val announcementService: AnnouncementService, private val featureFlagService: FeatureFlagService, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable override fun present(): HomeState { - val coroutineState = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { @@ -126,10 +130,11 @@ class HomePresenter( is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } - HomeEvent.DismissSwipeAvatarAnnouncement -> coroutineState.launch { + // The user is switching account, which destroys this presenter, so use the app scope to not be cancelled. + HomeEvent.DismissSwipeAvatarAnnouncement -> appCoroutineScope.launch { announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) } - HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { + HomeEvent.DismissMultiAccountAnnouncement -> coroutineScope.launch { announcementService.onAnnouncementDismissed(Announcement.MultiAccount) } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 6af53d2ace..8bd779ef05 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -39,8 +39,10 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -292,6 +294,7 @@ class HomePresenterTest { announcementService = FakeAnnouncementService( onAnnouncementDismissedResult = onAnnouncementDismissedResult, ), + appCoroutineScope = this, ) presenter.test { val state = expectMostRecentItem() @@ -313,7 +316,7 @@ class HomePresenterTest { } } -internal fun createHomePresenter( +internal fun TestScope.createHomePresenter( client: MatrixClient = FakeMatrixClient(), syncService: SyncService = FakeSyncService(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), @@ -323,6 +326,7 @@ internal fun createHomePresenter( sessionStore: SessionStore = InMemorySessionStore(), announcementService: AnnouncementService = FakeAnnouncementService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + appCoroutineScope: CoroutineScope = backgroundScope, ) = HomePresenter( client = client, syncService = syncService, @@ -334,4 +338,5 @@ internal fun createHomePresenter( sessionStore = sessionStore, announcementService = announcementService, featureFlagService = featureFlagService, + appCoroutineScope = appCoroutineScope, )