From 995a0524eda56eb3c9cc9568de1cbe5d3f29b23b Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:11:47 +0000 Subject: [PATCH 001/118] Add reactions support in the pinned messages list --- .../pinned/list/PinnedMessagesListEvent.kt | 2 + .../list/PinnedMessagesListPresenter.kt | 32 +++++++++++++--- .../pinned/list/PinnedMessagesListState.kt | 4 ++ .../list/PinnedMessagesListStateProvider.kt | 8 ++++ .../pinned/list/PinnedMessagesListView.kt | 37 ++++++++++++++++--- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt index 73ba08b29d..9be11d244a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt @@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.pinned.list import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId sealed interface PinnedMessagesListEvent { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent + data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : PinnedMessagesListEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 6cd037484d..fd19656fdc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -47,6 +49,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -71,6 +74,8 @@ class PinnedMessagesListPresenter( private val linkPresenter: Presenter, private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val actionListPresenter: Presenter, + private val customReactionPresenter: Presenter, + private val reactionSummaryPresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val analyticsService: AnalyticsService, @@ -88,7 +93,7 @@ class PinnedMessagesListPresenter( private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( config = TimelineItemsFactoryConfig( computeReadReceipts = false, - computeReactions = false, + computeReactions = true, ) ) @@ -96,6 +101,9 @@ class PinnedMessagesListPresenter( override fun present(): PinnedMessagesListState { htmlConverterProvider.Update() val roomInfo by room.roomInfoFlow.collectAsState() + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val timelineRoomInfo by remember { derivedStateOf { TimelineRoomInfo( @@ -103,7 +111,7 @@ class PinnedMessagesListPresenter( name = roomInfo.name, // We don't need to compute those values userHasPermissionToSendMessage = false, - userHasPermissionToSendReaction = false, + userHasPermissionToSendReaction = userEventPermissions.canSendReaction, // We do not care about the call state here. roomCallState = aStandByCallState(), // don't compute this value or the pin icon will be shown @@ -119,9 +127,8 @@ class PinnedMessagesListPresenter( } val timelineProtectionState = timelineProtectionPresenter.present() val linkState = linkPresenter.present() - val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> - perms.userEventPermissions() - } + val customReactionState = customReactionPresenter.present() + val reactionSummaryState = reactionSummaryPresenter.present() val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) @@ -138,6 +145,7 @@ class PinnedMessagesListPresenter( when (event) { is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event) is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId) + is PinnedMessagesListEvent.ToggleReaction -> sessionCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } } @@ -148,10 +156,20 @@ class PinnedMessagesListPresenter( displayThreadSummaries = displayThreadSummaries, userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, eventSink = ::handleEvent, ) } + private fun CoroutineScope.toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId) { + launch { + timelineProvider.invokeOnTimeline { + toggleReaction(emoji, eventOrTransactionId) + } + } + } + private fun CoroutineScope.handleTimelineAction( action: TimelineItemAction, targetEvent: TimelineItem.Event, @@ -233,6 +251,8 @@ class PinnedMessagesListPresenter( linkState: LinkState, userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, + customReactionState: CustomReactionState, + reactionSummaryState: ReactionSummaryState, eventSink: (PinnedMessagesListEvent) -> Unit ): PinnedMessagesListState { return when (timelineItems) { @@ -251,6 +271,8 @@ class PinnedMessagesListPresenter( userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, eventSink = eventSink ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index bf37029eff..6c55a0d90a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.ui.strings.CommonPlurals @@ -34,6 +36,8 @@ sealed interface PinnedMessagesListState { val timelineItems: ImmutableList, val actionListState: ActionListState, val linkState: LinkState, + val customReactionState: CustomReactionState, + val reactionSummaryState: ReactionSummaryState, val displayThreadSummaries: Boolean, val eventSink: (PinnedMessagesListEvent) -> Unit, ) : PinnedMessagesListState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt index bfa9820a44..23e1feac93 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -19,7 +19,11 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent @@ -92,6 +96,8 @@ fun aLoadedPinnedMessagesListState( linkState: LinkState = aLinkState(), timelineItems: List = emptyList(), actionListState: ActionListState = anActionListState(), + customReactionState: CustomReactionState = aCustomReactionState(), + reactionSummaryState: ReactionSummaryState = aReactionSummaryState(), aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, displayThreadSummaries: Boolean = false, eventSink: (PinnedMessagesListEvent) -> Unit = {} @@ -101,6 +107,8 @@ fun aLoadedPinnedMessagesListState( linkState = linkState, timelineItems = timelineItems.toImmutableList(), actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, userEventPermissions = aUserEventPermissions, displayThreadSummaries = displayThreadSummaries, eventSink = eventSink, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 7190aa174f..c900aa9f66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -32,8 +32,12 @@ import io.element.android.features.messages.impl.link.LinkEvent import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent @@ -195,11 +199,27 @@ private fun PinnedMessagesListLoaded( ) } + fun onReactionClick(emoji: String, event: TimelineItem.Event) { + event.eventOrTransactionId.let { eventOrTransactionId -> + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, eventOrTransactionId)) + } + } + + fun onReactionLongClick(emoji: String, event: TimelineItem.Event) { + event.eventId?.let { eventId -> + state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(eventId, event.reactionsState.reactions, emoji)) + } + } + + fun onMoreReactionsClick(event: TimelineItem.Event) { + state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event)) + } + ActionListView( state = state.actionListState, onSelectAction = ::onActionSelected, - onCustomReactionClick = {}, - onEmojiReactionClick = { _, _ -> }, + onCustomReactionClick = ::onMoreReactionsClick, + onEmojiReactionClick = ::onReactionClick, onVerifiedUserSendFailureClick = {} ) LazyColumn( @@ -230,9 +250,9 @@ private fun PinnedMessagesListLoaded( onLongClick = ::onMessageLongClick, displayThreadSummaries = displayThreadSummaries, inReplyToClick = {}, - onReactionClick = { _, _ -> }, - onReactionLongClick = { _, _ -> }, - onMoreReactionsClick = {}, + onReactionClick = ::onReactionClick, + onReactionLongClick = ::onReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, onReadReceiptClick = {}, onSwipeToReply = {}, eventSink = { timelineItemEvent -> @@ -262,6 +282,13 @@ private fun PinnedMessagesListLoaded( state.linkState, onLinkValid = onLinkClick, ) + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { eventOrTransactionId, emoji -> + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId)) + } + ) + ReactionSummaryView(state = state.reactionSummaryState) } @Composable From b1a3c2acb84999287420065ad827c2ec2368458c Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:34:59 +0000 Subject: [PATCH 002/118] Review + lint --- .../impl/pinned/list/PinnedMessagesListStateProvider.kt | 4 ++-- .../messages/impl/pinned/list/PinnedMessagesListView.kt | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt index 23e1feac93..19f19da0de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.link.LinkState @@ -22,8 +24,6 @@ import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.aCustomReactionState -import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 4a864eb3ae..49dafe5b2f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -205,9 +205,7 @@ private fun PinnedMessagesListLoaded( } fun onReactionClick(emoji: String, event: TimelineItem.Event) { - event.eventOrTransactionId.let { eventOrTransactionId -> - state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, eventOrTransactionId)) - } + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, event.eventOrTransactionId)) } fun onReactionLongClick(emoji: String, event: TimelineItem.Event) { From 21471e07fb5e3faeccc0b5683f58a7adb25e2a0d Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 3 Aug 2026 09:07:48 -0700 Subject: [PATCH 003/118] Add persistent account provider history to app preferences --- .../api/store/AppPreferencesStore.kt | 14 ++++ .../impl/store/DefaultAppPreferencesStore.kt | 27 +++++++ .../store/DefaultAppPreferencesStoreTest.kt | 70 +++++++++++++++++++ .../test/InMemoryAppPreferencesStore.kt | 15 ++++ 4 files changed, 126 insertions(+) diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index b1877b3ae5..d4ff5b3592 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -38,6 +38,20 @@ interface AppPreferencesStore { suspend fun setTracingLogLevel(logLevel: LogLevel) fun getTracingLogLevelFlow(): Flow + /** + * The list of account providers (homeserver URLs) the user has previously authenticated + * against, most recent first. Used to default the account provider and to power autocomplete + * suggestions during sign-in. Stored locally only; never synced across devices. + */ + fun getHomeserverHistoryFlow(): Flow> + + /** + * Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]). + * The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are + * moved to the front rather than duplicated, and the list is capped in size. No-op for blanks. + */ + suspend fun addHomeserverToHistory(url: String) + suspend fun setTracingLogPacks(targets: Set) fun getTracingLogPacksFlow(): Flow> diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index af35ac3c44..f8db0c11ee 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.preferences.impl.store +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") +private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory") private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri") private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion") private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName") @@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion") private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName") +// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String. +private const val HOMESERVER_HISTORY_DELIMITER = "\n" +private const val MAX_HOMESERVER_HISTORY_SIZE = 10 + @ContributesBinding(AppScope::class) class DefaultAppPreferencesStore( private val buildMeta: BuildMeta, @@ -168,6 +174,20 @@ class DefaultAppPreferencesStore( } } + override fun getHomeserverHistoryFlow(): Flow> { + return store.data.map { prefs -> prefs.readHomeserverHistory() } + } + + override suspend fun addHomeserverToHistory(url: String) { + val normalized = url.trim().lowercase() + if (normalized.isEmpty()) return + store.edit { prefs -> + val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized }) + .take(MAX_HOMESERVER_HISTORY_SIZE) + prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER) + } + } + override fun getMessageSoundFlow(): Flow { return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) } } @@ -242,6 +262,13 @@ class DefaultAppPreferencesStore( } } +private fun Preferences.readHomeserverHistory(): List { + return this[homeserverHistoryKey] + ?.split(HOMESERVER_HISTORY_DELIMITER) + ?.filter { it.isNotEmpty() } + ?: emptyList() +} + private fun BuildMeta.defaultLogLevel(): LogLevel { return when (buildType) { BuildType.DEBUG -> LogLevel.TRACE diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt index c52d1648ac..fbb9005ea5 100644 --- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt +++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt @@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest { assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25) } + + @Test + fun `homeserver history is empty by default`() = runTest { + val store = createStore() + assertThat(store.getHomeserverHistoryFlow().first()).isEmpty() + } + + @Test + fun `adding homeservers keeps the most recent first`() = runTest { + val store = createStore() + + store.addHomeserverToHistory("https://matrix.org") + store.addHomeserverToHistory("https://example.com") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com", "https://matrix.org") + .inOrder() + } + + @Test + fun `adding an existing homeserver moves it to the front without duplicating`() = runTest { + val store = createStore() + + store.addHomeserverToHistory("https://matrix.org") + store.addHomeserverToHistory("https://example.com") + store.addHomeserverToHistory("https://matrix.org") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://matrix.org", "https://example.com") + .inOrder() + } + + @Test + fun `homeservers are normalised and deduplicated case-insensitively`() = runTest { + val store = createStore() + + store.addHomeserverToHistory(" HTTPS://Matrix.ORG ") + store.addHomeserverToHistory("https://matrix.org") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://matrix.org") + } + + @Test + fun `blank homeservers are ignored`() = runTest { + val store = createStore() + + store.addHomeserverToHistory(" ") + + assertThat(store.getHomeserverHistoryFlow().first()).isEmpty() + } + + @Test + fun `homeserver history is capped to the most recent entries`() = runTest { + val store = createStore() + + repeat(15) { index -> + store.addHomeserverToHistory("https://server$index.org") + } + + val history = store.getHomeserverHistoryFlow().first() + assertThat(history).hasSize(10) + assertThat(history.first()).isEqualTo("https://server14.org") + assertThat(history.last()).isEqualTo("https://server5.org") + } + + private fun createStore() = DefaultAppPreferencesStore( + buildMeta = buildMeta, + preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + ) } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index 6499e70223..e8d0b0bb8e 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet class InMemoryAppPreferencesStore( @@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore( liveLocationMinimumDistanceUpdate: Int = 10, logLevel: LogLevel = LogLevel.INFO, traceLogPacks: Set = emptySet(), + homeserverHistory: List = emptyList(), messageSound: NotificationSound = NotificationSound.SystemDefault, messageSoundChannelVersion: Int = 0, messageSoundDisplayName: String? = null, @@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore( private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val logLevel = MutableStateFlow(logLevel) private val tracingLogPacks = MutableStateFlow(traceLogPacks) + private val homeserverHistory = MutableStateFlow(homeserverHistory) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) private val messageSound = MutableStateFlow(messageSound) @@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore( return tracingLogPacks } + override fun getHomeserverHistoryFlow(): Flow> { + return homeserverHistory + } + + override suspend fun addHomeserverToHistory(url: String) { + val normalized = url.trim().lowercase() + if (normalized.isEmpty()) return + homeserverHistory.update { current -> + (listOf(normalized) + current.filter { it != normalized }).take(10) + } + } + override fun getMessageSoundFlow(): Flow { return messageSound } From 4714127cc053e06791c86775734f70ab847ff0ac Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 3 Aug 2026 10:54:45 -0700 Subject: [PATCH 004/118] Default the account provider to the most recently used one --- features/login/impl/build.gradle.kts | 2 + .../AccountProviderDataSource.kt | 39 ++++++++++-- .../login/impl/DefaultLoginEntryPointTest.kt | 5 +- .../AccountProviderDataSourceFixture.kt | 25 ++++++++ .../AccountProviderDataSourceTest.kt | 62 ++++++++++++++++--- .../changeserver/ChangeServerPresenterTest.kt | 3 +- .../LoginWithClassicPresenterTest.kt | 4 +- .../ConfirmAccountProviderPresenterTest.kt | 4 +- .../createaccount/DefaultMessageParserTest.kt | 5 +- .../LoginPasswordPresenterTest.kt | 4 +- .../onboarding/OnBoardingPresenterTest.kt | 5 +- 11 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 86a8e0e7bc..6cf8945e05 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) implementation(projects.libraries.oauth.api) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiUtils) implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) @@ -80,6 +81,7 @@ dependencies { testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.preferences.test) + testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt index 931959d4ea..28e4294fa4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -13,27 +13,58 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.canConnectToAnyHomeserver +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch @SingleIn(AppScope::class) @Inject class AccountProviderDataSource( - enterpriseService: EnterpriseService, + private val enterpriseService: EnterpriseService, + private val appPreferencesStore: AppPreferencesStore, + @AppCoroutineScope private val coroutineScope: CoroutineScope, ) { - private val defaultAccountProvider = createAccountProvider( + // The provider used when the user has not selected one: an enterprise/MDM-configured provider, + // else matrix.org. The most recently used provider (from history) can override it, see init. + private val configuredAccountProvider = createAccountProvider( url = enterpriseService.defaultHomeserverList() .firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: AuthenticationConfig.MATRIX_ORG_URL ) - private val accountProvider: MutableStateFlow = MutableStateFlow(defaultAccountProvider) + private val accountProvider: MutableStateFlow = MutableStateFlow(configuredAccountProvider) val flow: StateFlow = accountProvider.asStateFlow() + init { + // Seed the default from the last used provider, unless the user has already selected one. + coroutineScope.launch { + val default = defaultAccountProvider() + accountProvider.update { current -> if (current == configuredAccountProvider) default else current } + } + } + suspend fun reset() { - accountProvider.emit(defaultAccountProvider) + accountProvider.emit(defaultAccountProvider()) + } + + /** + * The provider to default to: the most recently used one from history when the user is free to + * connect to any provider, otherwise the enterprise/MDM-configured provider. + */ + private suspend fun defaultAccountProvider(): AccountProvider { + if (!enterpriseService.canConnectToAnyHomeserver()) { + return configuredAccountProvider + } + val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull() + return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider } suspend fun setUrl(url: String) { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index a05194d008..4e040a7556 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -12,9 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.api.LoginEntryPoint -import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.features.preferences.test.FakePreferencesEntryPoint import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow @@ -38,7 +37,7 @@ class DefaultLoginEntryPointTest { LoginFlowNode( buildContext = buildContext, plugins = plugins, - accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource = anAccountProviderDataSource(), oAuthActionFlow = FakeOAuthActionFlow(), appCoroutineScope = backgroundScope, elementClassicConnection = FakeElementClassicConnection(), diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt new file mode 100644 index 0000000000..e8f9dbefb9 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +fun anAccountProviderDataSource( + enterpriseService: EnterpriseService = FakeEnterpriseService(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined), +) = AccountProviderDataSource( + enterpriseService = enterpriseService, + appPreferencesStore = appPreferencesStore, + coroutineScope = coroutineScope, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt index f86df13335..30e25c8c3e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -24,7 +25,7 @@ class AccountProviderDataSourceTest { @Test fun `present - initial state`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState).isEqualTo( @@ -41,10 +42,10 @@ class AccountProviderDataSourceTest { @Test fun `present - initial state - matrix org`() = runTest { - val sut = AccountProviderDataSource( - FakeEnterpriseService( + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) } - ) + ), ) sut.flow.test { val initialState = awaitItem() @@ -62,10 +63,10 @@ class AccountProviderDataSourceTest { @Test fun `present - ensure that default homeserver is not star char`() = runTest { - val sut = AccountProviderDataSource( - FakeEnterpriseService( + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) } - ) + ), ) sut.flow.test { val initialState = awaitItem() @@ -83,7 +84,7 @@ class AccountProviderDataSourceTest { @Test fun `present - user change and reset`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) @@ -106,7 +107,7 @@ class AccountProviderDataSourceTest { @Test fun `present - set url and reset`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) @@ -126,4 +127,47 @@ class AccountProviderDataSourceTest { assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) } } + + @Test + fun `present - defaults to the most recently used provider from history`() = runTest { + val sut = anAccountProviderDataSource( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com", "https://matrix.org"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://example.com") + } + } + + @Test + fun `present - history is ignored when the account provider is enforced`() = runTest { + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf("https://enforced.org") } + ), + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://enforced.org") + } + } + + @Test + fun `present - reset returns to the most recently used provider from history`() = runTest { + val sut = anAccountProviderDataSource( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://example.com") + sut.setUrl("https://other.com") + assertThat(awaitItem().url).isEqualTo("https://other.com") + sut.reset() + assertThat(awaitItem().url).isEqualTo("https://example.com") + } + } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index 5d9040adac..868f56f8a7 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -14,6 +14,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.wellknown.test.FakeWellknownRetriever @@ -259,7 +260,7 @@ class ChangeServerPresenterTest { private fun createPresenter( authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), enterpriseService: EnterpriseService = FakeEnterpriseService(), wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt index 8d56386fa4..d4a1a74d99 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt @@ -8,8 +8,8 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.features.login.impl.classic.ElementClassicConnectionState import io.element.android.features.login.impl.classic.FakeElementClassicConnection @@ -249,7 +249,7 @@ private fun createPresenter( navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(), loginModePresenter: LoginModePresenter = createLoginModePresenter(), elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), isEnterpriseBuild: Boolean = false, ) = LoginWithClassicPresenter( userId = userId, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index c20896870b..ce8e60c73f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -10,8 +10,8 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter @@ -378,7 +378,7 @@ class ConfirmAccountProviderPresenterTest { private fun createConfirmAccountProviderPresenter( params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt index 680fda2838..e3fea27557 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt @@ -10,8 +10,7 @@ package io.element.android.features.login.impl.screens.createaccount import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.matrix.api.auth.external.ExternalSession import kotlinx.serialization.SerializationException @@ -70,7 +69,7 @@ class DefaultMessageParserTest { private fun createDefaultMessageParser(): DefaultMessageParser { return DefaultMessageParser( - accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource = anAccountProviderDataSource(), json = DefaultJsonProvider(), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 31a835cb8c..4133403c51 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -10,8 +10,8 @@ package io.element.android.features.login.impl.screens.loginpassword import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EXCEPTION @@ -157,7 +157,7 @@ class LoginPasswordPresenterTest { private fun createLoginPasswordPresenter( initialLogin: String = "", authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), ): LoginPasswordPresenter = LoginPasswordPresenter( initialLogin = initialLogin, authenticationService = authenticationService, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 79268aa1e4..b14e288d7e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.login.LoginModePresenter import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever @@ -247,7 +248,7 @@ class OnBoardingPresenterTest { Result.failure(AN_EXCEPTION) }, ) - val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()) + val accountProviderDataSource = anAccountProviderDataSource() val presenter = createPresenter( params = OnBoardingNode.Params( accountProvider = A_HOMESERVER_URL, @@ -297,7 +298,7 @@ private fun createPresenter( loginModePresenter: LoginModePresenter = createLoginModePresenter(), onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null }, sessionStore: SessionStore = InMemorySessionStore(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), ) = OnBoardingPresenter( params = params, buildMeta = buildMeta, From 3c09fb37b92a254a81286fab01a81dac21a9c7ab Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 3 Aug 2026 11:18:05 -0700 Subject: [PATCH 005/118] Save the account provider to history on successful sign-in --- .../SaveAccountProviderToHistory.kt | 28 +++++++++++++++ .../login/impl/login/LoginModePresenter.kt | 3 ++ .../createaccount/CreateAccountPresenter.kt | 3 ++ .../loginpassword/LoginPasswordPresenter.kt | 3 ++ .../SaveAccountProviderToHistoryTest.kt | 34 +++++++++++++++++++ .../CreateAccountPresenterTest.kt | 30 ++++++++++++++++ .../LoginPasswordPresenterTest.kt | 32 +++++++++++++++++ .../onboarding/OnBoardingPresenterTest.kt | 5 +++ 8 files changed, 138 insertions(+) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt new file mode 100644 index 0000000000..a88be1ae15 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.preferences.api.store.AppPreferencesStore + +/** + * Persist the account provider the user just authenticated against, so that it can be offered as + * the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next + * sign-in. To be called from the manual sign-in / account creation flows only, on success. + * + * See [AppPreferencesStore.addHomeserverToHistory]. + */ +@Inject +class SaveAccountProviderToHistory( + private val accountProviderDataSource: AccountProviderDataSource, + private val appPreferencesStore: AppPreferencesStore, +) { + suspend operator fun invoke() { + appPreferencesStore.addHomeserverToHistory(accountProviderDataSource.flow.value.url) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt index 34188095b0..1bb85fb6c9 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter @@ -41,6 +42,7 @@ class LoginModePresenter( private val authenticationService: MatrixAuthenticationService, private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever, private val localNetworkPermissionGate: LocalNetworkPermissionGate, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @Composable override fun present(): LoginModeState { @@ -120,6 +122,7 @@ class LoginModePresenter( .onSuccess { loginMode.value = AsyncData.Uninitialized } .onFailure { loginMode.value = AsyncData.Failure(it) } is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url) + .onSuccess { saveAccountProviderToHistory() } .onFailure { loginMode.value = AsyncData.Failure(it) } } oAuthActionFlow.reset() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt index bf7af111f4..d750586e94 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.flatMap @@ -33,6 +34,7 @@ class CreateAccountPresenter( private val authenticationService: MatrixAuthenticationService, private val messageParser: MessageParser, private val buildMeta: BuildMeta, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @AssistedFactory interface Factory { @@ -74,6 +76,7 @@ class CreateAccountPresenter( }.flatMap { externalSession -> authenticationService.importCreatedSession(externalSession) }.onSuccess { sessionId -> + saveAccountProviderToHistory() loggedInState.value = AsyncAction.Success(sessionId) }.onFailure { failure -> loggedInState.value = AsyncAction.Failure(failure) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt index f26f342a42..6e28bd8781 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -20,6 +20,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -33,6 +34,7 @@ class LoginPasswordPresenter( private val initialLogin: String, private val authenticationService: MatrixAuthenticationService, private val accountProviderDataSource: AccountProviderDataSource, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @AssistedFactory interface Factory { @@ -83,6 +85,7 @@ class LoginPasswordPresenter( loggedInState.value = AsyncData.Loading() authenticationService.login(formState.login.trim(), formState.password) .onSuccess { sessionId -> + saveAccountProviderToHistory() loggedInState.value = AsyncData.Success(sessionId) } .onFailure { failure -> diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt new file mode 100644 index 0000000000..957235395f --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SaveAccountProviderToHistoryTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `invoke saves the current account provider url to history`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val accountProviderDataSource = anAccountProviderDataSource() + accountProviderDataSource.setUrl("https://example.com") + val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore) + + sut() + + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com") + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt index c70a5c9a9a..d2c9cf32b2 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt @@ -9,6 +9,10 @@ package io.element.android.features.login.impl.screens.createaccount import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -19,10 +23,13 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore 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.flow.first import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -94,6 +101,26 @@ class CreateAccountPresenterTest { lambda.assertions().isCalledOnce().with(value("aMessage")) } + @Test + fun `present - successful account creation saves the account provider to history`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val presenter = createPresenter( + authenticationService = FakeMatrixAuthenticationService( + importCreatedSessionLambda = { Result.success(A_SESSION_ID) } + ), + messageParser = FakeMessageParser { anExternalSession() }, + appPreferencesStore = appPreferencesStore, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage")) + assertThat(awaitItem().createAction.isLoading()).isTrue() + assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID) + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + } + } + @Test fun `present - receiving a message able to be parsed but error in importing change the state to error`() = runTest { val presenter = createPresenter( @@ -115,10 +142,13 @@ class CreateAccountPresenterTest { authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), messageParser: MessageParser = FakeMessageParser(), buildMeta: BuildMeta = aBuildMeta(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), ) = CreateAccountPresenter( url = url, authenticationService = authenticationService, messageParser = messageParser, buildMeta = buildMeta, + saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 4133403c51..4dd2859dee 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.loginpassword import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.SessionId @@ -21,8 +22,11 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME_2 import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -101,6 +105,32 @@ class LoginPasswordPresenterTest { } } + @Test + fun `present - successful login saves the account provider to history`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + val appPreferencesStore = InMemoryAppPreferencesStore() + createLoginPasswordPresenter( + authenticationService = authenticationService, + appPreferencesStore = appPreferencesStore, + ).test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + skipItems(1) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID)) + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + } + } + @Test fun `present - submit with error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( @@ -158,9 +188,11 @@ class LoginPasswordPresenterTest { initialLogin: String = "", authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), ): LoginPasswordPresenter = LoginPasswordPresenter( initialLogin = initialLogin, authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, + saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index b14e288d7e..49ea0e3caa 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.login.LoginModePresenter @@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData @@ -322,6 +324,8 @@ fun createLoginModePresenter( io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(), permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory = io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(), + saveAccountProviderToHistory: SaveAccountProviderToHistory = + SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()), ): LoginModePresenter = LoginModePresenter( oAuthActionFlow = oAuthActionFlow, authenticationService = authenticationService, @@ -330,4 +334,5 @@ fun createLoginModePresenter( advisor = localNetworkPermissionAdvisor, permissionsPresenterFactory = permissionsPresenterFactory, ), + saveAccountProviderToHistory = saveAccountProviderToHistory, ) From df6ccd9dc30312b766f41e22685e36756e60c5bd Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 6 Aug 2026 11:31:25 -0700 Subject: [PATCH 006/118] Consolidate account provider selection into a single editable screen --- .../features/login/impl/LoginFlowNode.kt | 43 ----- .../ConfirmAccountProviderEvents.kt | 1 + .../ConfirmAccountProviderNode.kt | 2 - .../ConfirmAccountProviderPresenter.kt | 46 +++++- .../ConfirmAccountProviderState.kt | 14 +- .../ConfirmAccountProviderStateProvider.kt | 10 +- .../ConfirmAccountProviderView.kt | 128 ++++++++++----- .../ConfirmAccountProviderPresenterTest.kt | 148 +++++++++--------- .../src/main/res/values/temporary.xml | 5 + 9 files changed, 226 insertions(+), 171 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 978d28dfa3..7437051e01 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.replace -import com.bumble.appyx.navmodel.backstack.operation.singleTop import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject @@ -33,14 +32,12 @@ import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode -import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode import io.element.android.features.login.impl.screens.classic.ClassicFlowNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode -import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.BackstackView @@ -130,12 +127,6 @@ class LoginFlowNode( @Parcelize data object ChooseAccountProvider : NavTarget - @Parcelize - data object ChangeAccountProvider : NavTarget - - @Parcelize - data object SearchAccountProvider : NavTarget - @Parcelize data class LoginPassword( val initialLogin: String = "", @@ -281,43 +272,9 @@ class LoginFlowNode( override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword()) } - - override fun navigateToChangeAccountProvider() { - backstack.push(NavTarget.ChangeAccountProvider) - } } createNode(buildContext, plugins = listOf(inputs, callback)) } - NavTarget.ChangeAccountProvider -> { - val callback = object : ChangeAccountProviderNode.Callback { - override fun onDone() { - // Go back to the Account Provider screen - val confirmAccountProvider = backstack.elements.value.firstOrNull { - it.key.navTarget is NavTarget.ConfirmAccountProvider - }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) - backstack.singleTop(confirmAccountProvider) - } - - override fun navigateToSearchAccountProvider() { - backstack.push(NavTarget.SearchAccountProvider) - } - } - - createNode(buildContext, plugins = listOf(callback)) - } - NavTarget.SearchAccountProvider -> { - val callback = object : SearchAccountProviderNode.Callback { - override fun onDone() { - // Go back to the Account Provider screen - val confirmAccountProvider = backstack.elements.value.firstOrNull { - it.key.navTarget is NavTarget.ConfirmAccountProvider - }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) - backstack.singleTop(confirmAccountProvider) - } - } - - createNode(buildContext, plugins = listOf(callback)) - } is NavTarget.LoginPassword -> { val inputs = LoginPasswordNode.Inputs( initialLogin = navTarget.initialLogin, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt index d66606b22c..1fb024156b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -9,6 +9,7 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider sealed interface ConfirmAccountProviderEvents { + data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents data object Continue : ConfirmAccountProviderEvents data object ClearError : ConfirmAccountProviderEvents } 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 928a493dc1..417b0d4850 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 @@ -46,7 +46,6 @@ class ConfirmAccountProviderNode( fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) fun navigateToCreateAccount(url: String) - fun navigateToChangeAccountProvider() } private val callback: Callback = callback() @@ -61,7 +60,6 @@ class ConfirmAccountProviderNode( onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onCreateAccountContinue = callback::navigateToCreateAccount, - onChange = callback::navigateToChangeAccountProvider, onLearnMoreClick = { openLearnMorePage(context) }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 0111b1e7d7..de6ec28774 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -9,14 +9,23 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @AssistedInject @@ -24,6 +33,7 @@ class ConfirmAccountProviderPresenter( @Assisted private val params: Params, private val accountProviderDataSource: AccountProviderDataSource, private val loginModePresenter: Presenter, + private val changeServerPresenter: Presenter, ) : Presenter { data class Params( val isAccountCreation: Boolean, @@ -38,25 +48,49 @@ class ConfirmAccountProviderPresenter( override fun present(): ConfirmAccountProviderState { val accountProvider by accountProviderDataSource.flow.collectAsState() val loginModeState = loginModePresenter.present() + val changeServerState = changeServerPresenter.present() - fun handleEvent(event: ConfirmAccountProviderEvents) { - when (event) { - ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink( + // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. + var userInput by rememberSaveable { mutableStateOf(null) } + val accountProviderInput = userInput ?: accountProvider.url + val latestAccountProviderInput by rememberUpdatedState(accountProviderInput) + + // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. + LaunchedEffect(changeServerState.changeServerAction) { + if (changeServerState.changeServerAction is AsyncData.Success) { + loginModeState.eventSink( LoginModeEvent.Submit( isAccountCreation = params.isAccountCreation, - homeserverUrl = accountProvider.url, + homeserverUrl = latestAccountProviderInput.trim(), resolvedHomeserverUrl = null, loginHint = null, ) ) - ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError) + } + } + + fun handleEvent(event: ConfirmAccountProviderEvents) { + when (event) { + is ConfirmAccountProviderEvents.UserInputChanged -> { + userInput = event.accountProvider + } + // Validate (and persist) the chosen account provider before proceeding. This also enforces the + // enterprise account-provider access control, which the login submit does not do on its own. + ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink( + ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderInput.trim())) + ) + ConfirmAccountProviderEvents.ClearError -> { + loginModeState.eventSink(LoginModeEvent.ClearError) + changeServerState.eventSink(ChangeServerEvents.ClearError) + } } } return ConfirmAccountProviderState( - accountProvider = accountProvider, + accountProviderInput = accountProviderInput, isAccountCreation = params.isAccountCreation, loginModeState = loginModeState, + changeServerState = changeServerState, eventSink = ::handleEvent, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt index 6e6deb8c2c..0c211341ef 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -8,17 +8,23 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider -import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData data class ConfirmAccountProviderState( - val accountProvider: AccountProvider, + val accountProviderInput: String, val isAccountCreation: Boolean, val loginModeState: LoginModeState, + val changeServerState: ChangeServerState, val eventSink: (ConfirmAccountProviderEvents) -> Unit ) { val submitEnabled: Boolean - get() = accountProvider.url.isNotEmpty() && - (loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading) + get() = accountProviderInput.isNotBlank() && + loginModeState.loginMode is AsyncData.Uninitialized && + changeServerState.changeServerAction is AsyncData.Uninitialized + + val isLoading: Boolean + get() = loginModeState.loginMode is AsyncData.Loading || + changeServerState.changeServerAction is AsyncData.Loading } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt index f597ad6b54..73c5e5f550 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt @@ -9,8 +9,8 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported @@ -31,13 +31,15 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider Unit = {}, ) = ConfirmAccountProviderState( - accountProvider = accountProvider, + accountProviderInput = accountProviderInput, isAccountCreation = isAccountCreation, loginModeState = loginModeState, + changeServerState = changeServerState, eventSink = eventSink ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 95fbff25ab..3a7d2831c8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -8,29 +8,38 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeView -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext 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.TextButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView import io.element.android.libraries.testtags.TestTags @@ -44,14 +53,8 @@ fun ConfirmAccountProviderView( onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, onCreateAccountContinue: (url: String) -> Unit, - onChange: () -> Unit, modifier: Modifier = Modifier, ) { - val isLoading by remember(state.loginModeState.loginMode) { - derivedStateOf { - state.loginModeState.loginMode is AsyncData.Loading - } - } val eventSink = state.eventSink HeaderFooterPage( @@ -62,55 +65,97 @@ fun ConfirmAccountProviderView( iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { - R.string.screen_account_provider_signup_title + CommonStrings.screen_select_server_title_register } else { - R.string.screen_account_provider_signin_title - }, - state.accountProvider.title + CommonStrings.screen_select_server_title_login + } ), - subTitle = stringResource( - id = if (state.isAccountCreation) { - R.string.screen_account_provider_signup_subtitle - } else { - R.string.screen_account_provider_signin_subtitle - }, - ) + subTitle = null, ) }, footer = { ButtonColumnMolecule { Button( text = stringResource(id = CommonStrings.action_continue), - showProgress = isLoading, - onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, - enabled = state.submitEnabled || isLoading, + showProgress = state.isLoading, + onClick = { eventSink(ConfirmAccountProviderEvents.Continue) }, + enabled = state.submitEnabled && !state.isLoading, modifier = Modifier .fillMaxWidth() .testTag(TestTags.loginContinue) ) - TextButton( - text = stringResource(id = R.string.screen_account_provider_change), - onClick = onChange, - enabled = !isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.loginChangeServer) - ) } } ) { - LoginModeView( - loginMode = state.loginModeState.loginMode, - onClearError = { - eventSink(ConfirmAccountProviderEvents.ClearError) + var input by textFieldState(stateValue = state.accountProviderInput) + val focusManager = LocalFocusManager.current + TextField( + value = input, + onValueChange = { + input = it + eventSink(ConfirmAccountProviderEvents.UserInputChanged(it)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp) + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.changeServerServer), + label = stringResource(id = CommonStrings.screen_select_server_textfield_header), + supportingText = stringResource( + id = if (state.isAccountCreation) { + CommonStrings.screen_select_server_textfield_footer_register + } else { + CommonStrings.screen_select_server_textfield_footer_login + } + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { + eventSink(ConfirmAccountProviderEvents.Continue) + }), + singleLine = true, + trailingIcon = if (input.isNotEmpty()) { + { + Box( + Modifier.clickable( + onClickLabel = stringResource(CommonStrings.action_clear), + role = Role.Button, + ) { + input = "" + eventSink(ConfirmAccountProviderEvents.UserInputChanged("")) + } + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear) + ) + } + } + } else { + null }, - onLearnMoreClick = onLearnMoreClick, - onOAuthDetails = onOAuthDetails, - onNeedLoginPassword = onNeedLoginPassword, - onCreateAccountContinue = onCreateAccountContinue, ) } + // Renders the account-provider validation errors, progress and permission dialogs. + // The successful validation itself is observed by the presenter, which then proceeds with the login flow. + ChangeServerView( + state = state.changeServerState, + onLearnMoreClick = onLearnMoreClick, + onSuccess = {}, + ) + + LoginModeView( + loginMode = state.loginModeState.loginMode, + onClearError = { eventSink(ConfirmAccountProviderEvents.ClearError) }, + onLearnMoreClick = onLearnMoreClick, + onOAuthDetails = onOAuthDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) + LocalNetworkPermissionDialogView( dialog = state.loginModeState.localNetworkPermissionDialog, onSubmit = { @@ -133,6 +178,5 @@ internal fun ConfirmAccountProviderViewPreview( onNeedLoginPassword = {}, onCreateAccountContinue = {}, onLearnMoreClick = {}, - onChange = {}, ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index ce8e60c73f..0257d68ea9 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -8,15 +8,21 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever +import io.element.android.features.wellknown.test.FakeWellknownRetriever import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.AN_EXCEPTION @@ -25,6 +31,9 @@ import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest @@ -42,7 +51,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.submitEnabled).isTrue() - assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.accountProviderInput).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -60,13 +69,9 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) + cancelAndIgnoreRemainingEvents() } } @@ -83,13 +88,9 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -108,17 +109,13 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenOAuthCancelError(AN_EXCEPTION) defaultOAuthActionFlow.post(OAuthAction.GoBack()) - val cancelFailureState = awaitItem() + val cancelFailureState = awaitLoginMode { it is AsyncData.Failure } assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -137,16 +134,12 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOAuthActionFlow.post(OAuthAction.GoBack()) - val cancelFinalState = awaitItem() + val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -165,12 +158,11 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + awaitLoginMode { it is AsyncData.Loading } defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) - val cancelFinalState = awaitItem() + val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -189,19 +181,13 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenLoginError(AN_EXCEPTION) defaultOAuthActionFlow.post(OAuthAction.Success("aUrl")) - val cancelLoadingState = awaitItem() - assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val cancelFailureState = awaitItem() + val cancelFailureState = awaitLoginMode { it is AsyncData.Failure } assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -220,16 +206,12 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) - val successSuccessState = awaitItem() + val successSuccessState = awaitLoginMode { it is AsyncData.Loading } assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -246,10 +228,10 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val failureState = awaitItem() - assertThat(failureState.submitEnabled).isFalse() - assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + // The account provider validation fails, so the login is never attempted. + val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } + assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) + cancelAndIgnoreRemainingEvents() } } @@ -266,18 +248,15 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() - // Submit will return an error + // Submit will return an error while validating the account provider initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - // Check an error was returned - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } // Assert the error is then cleared submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) - val clearedState = awaitItem() + val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized } assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -286,7 +265,7 @@ class ConfirmAccountProviderPresenterTest { fun `present - confirm account creation without oidc and without url generates an error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( setHomeserverResult = { - Result.success(aMatrixHomeServerDetails()) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, ) val presenter = createConfirmAccountProviderPresenter( @@ -299,14 +278,14 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading // Check an error was returned - val submittedState = awaitItem() + val submittedState = awaitLoginMode { it is AsyncData.Failure } assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) // Assert the error is then cleared submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) - val clearedState = awaitItem() + val clearedState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) + cancelAndIgnoreRemainingEvents() } } @@ -324,10 +303,9 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -347,10 +325,9 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -359,7 +336,7 @@ class ConfirmAccountProviderPresenterTest { val aUrl = "aUrl" val authenticationService = FakeMatrixAuthenticationService( setHomeserverResult = { - Result.success(aMatrixHomeServerDetails()) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, ) val presenter = createConfirmAccountProviderPresenter( @@ -370,9 +347,26 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val submittedState = awaitItem() + val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl)) + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate + * account-provider validation states, and returns that state. + */ + private suspend fun ReceiveTurbine.awaitLoginMode( + predicate: (AsyncData) -> Boolean, + ): ConfirmAccountProviderState = awaitState { predicate(it.loginModeState.loginMode) } + + private suspend fun ReceiveTurbine.awaitState( + predicate: (ConfirmAccountProviderState) -> Boolean, + ): ConfirmAccountProviderState { + while (true) { + val item = awaitItem() + if (predicate(item)) return item } } @@ -390,5 +384,19 @@ class ConfirmAccountProviderPresenterTest { oAuthActionFlow = defaultOAuthActionFlow, webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever, ), + changeServerPresenter = ChangeServerPresenter( + authenticationService = matrixAuthenticationService, + accountProviderDataSource = accountProviderDataSource, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + ), + wellknownRetriever = FakeWellknownRetriever(), + ), + localNetworkPermissionGate = LocalNetworkPermissionGate( + advisor = FakeLocalNetworkPermissionAdvisor(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()), + ), + ), ) } diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index c704e811ff..ca01aead32 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -10,4 +10,9 @@ "Collapsed" Active Inactive + "Enter your account provider" + "Choose an account provider" + "Account provider" + "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." + "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." From c2b3b0fe0f546a5c4192e9bb5b4cb490d9d1e66d Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 6 Aug 2026 11:31:32 -0700 Subject: [PATCH 007/118] Remove the account provider search screen and homeserver resolver --- .../AccountProviderOtherView.kt | 77 ------- .../login/impl/resolver/HomeserverData.kt | 14 -- .../login/impl/resolver/HomeserverResolver.kt | 82 ------- .../ChangeAccountProviderNode.kt | 51 ----- .../ChangeAccountProviderPresenter.kt | 57 ----- .../ChangeAccountProviderState.kt | 19 -- .../ChangeAccountProviderStateProvider.kt | 37 --- .../ChangeAccountProviderView.kt | 129 ----------- .../SearchAccountProviderEvents.kt | 17 -- .../SearchAccountProviderNode.kt | 49 ---- .../SearchAccountProviderPresenter.kt | 77 ------- .../SearchAccountProviderState.kt | 20 -- .../SearchAccountProviderStateProvider.kt | 48 ---- .../SearchAccountProviderView.kt | 214 ------------------ .../ChangeAccountProviderPresenterTest.kt | 113 --------- .../SearchAccountProviderPresenterTest.kt | 179 --------------- 16 files changed, 1183 deletions(-) delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt delete mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt delete mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt deleted file mode 100644 index 8b9b22022e..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.accountprovider - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.HorizontalDivider -import io.element.android.libraries.designsystem.theme.components.Text - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 - */ -@Composable -fun AccountProviderOtherView( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .clickable(role = Role.Button) { onClick() } - ) { - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(vertical = 4.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RoundedIconAtom( - size = RoundedIconAtomSize.Medium, - imageVector = CompoundIcons.Search(), - tint = ElementTheme.colors.iconPrimary, - ) - Text( - modifier = Modifier - .padding(start = 16.dp) - .weight(1f), - text = stringResource(R.string.screen_change_account_provider_other), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun AccountProviderOtherViewPreview() = ElementPreview { - AccountProviderOtherView( - onClick = { }, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt deleted file mode 100644 index d9994deb9a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.resolver - -data class HomeserverData( - // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url - val homeserverUrl: String, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt deleted file mode 100644 index 8c6cab79ce..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.resolver - -import dev.zacsweers.metro.Inject -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.core.coroutine.parallelMap -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.core.uri.isValidUrl -import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker -import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.util.Collections - -/** - * Resolve homeserver base on search terms. - */ -@Inject -class HomeserverResolver( - private val dispatchers: CoroutineDispatchers, - private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, - private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, -) { - fun resolve(userInput: String): Flow> = flow { - val flowContext = currentCoroutineContext() - val trimmedUserInput = userInput.trim() - if (trimmedUserInput.length < 4) return@flow - val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/") - val list = getUrlCandidates(candidateBase) - val currentList = Collections.synchronizedList(mutableListOf()) - // Run all the requests in parallel - withContext(dispatchers.io) { - list.parallelMap { url -> - // Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first — - // otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a - // candidate directly; the actual sign-in flow triggers the permission prompt. - val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) || - homeServerLoginCompatibilityChecker.check(url) - .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") } - .getOrNull() - ?: return@parallelMap - - // Emit the list as soon as possible - if (shouldRequestPermissionOrIsValid) { - currentList.add(HomeserverData(homeserverUrl = url)) - withContext(flowContext) { - emit(currentList.toList()) - } - } - } - } - // If list is empty, and candidateBase is a valid an URL, do not block the user. - // A unsupported homeserver / homeserver not found error will be displayed if the user continues - if (currentList.isEmpty() && candidateBase.isValidUrl()) { - emit(listOf(HomeserverData(homeserverUrl = candidateBase))) - } - } - - private fun getUrlCandidates(data: String): List { - return buildList { - if (data.contains(".")) { - // TLD detected? - } else { - add("$data.org") - add("$data.com") - add("$data.io") - } - // Always try what the user has entered - add(data) - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt deleted file mode 100644 index 018a25dc89..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.login.impl.util.openLearnMorePage -import io.element.android.libraries.architecture.callback - -@ContributesNode(AppScope::class) -@AssistedInject -class ChangeAccountProviderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - private val presenter: ChangeAccountProviderPresenter, -) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { - fun onDone() - fun navigateToSearchAccountProvider() - } - - private val callback: Callback = callback() - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - val context = LocalContext.current - ChangeAccountProviderView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onLearnMoreClick = { openLearnMorePage(context) }, - onSuccess = callback::onDone, - onOtherProviderClick = callback::navigateToSearchAccountProvider, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt deleted file mode 100644 index 41605352ef..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import dev.zacsweers.metro.Inject -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.api.canConnectToAnyHomeserver -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.uri.ensureProtocol -import kotlinx.collections.immutable.toImmutableList - -@Inject -class ChangeAccountProviderPresenter( - private val changeServerPresenter: Presenter, - private val enterpriseService: EnterpriseService, -) : Presenter { - @Composable - override fun present(): ChangeAccountProviderState { - val staticAccountProviderList = remember { - enterpriseService.defaultHomeserverList() - .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } - .map { it.ensureProtocol() } - .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) } - .map { url -> - AccountProvider( - url = url, - subtitle = null, - isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, - isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - ) - } - .toImmutableList() - } - - val canSearchForAccountProviders = remember { - enterpriseService.canConnectToAnyHomeserver() - } - - val changeServerState = changeServerPresenter.present() - return ChangeAccountProviderState( - accountProviders = staticAccountProviderList, - canSearchForAccountProviders = canSearchForAccountProviders, - changeServerState = changeServerState, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt deleted file mode 100644 index 3068a1cb57..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import kotlinx.collections.immutable.ImmutableList - -data class ChangeAccountProviderState( - val accountProviders: ImmutableList, - val canSearchForAccountProviders: Boolean, - val changeServerState: ChangeServerState, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt deleted file mode 100644 index 5cec1961a4..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.anAccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.changeserver.aChangeServerState -import kotlinx.collections.immutable.toImmutableList - -open class ChangeAccountProviderStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aChangeAccountProviderState(), - aChangeAccountProviderState(canSearchForAccountProviders = false), - // Add other state here - ) -} - -fun aChangeAccountProviderState( - accountProviders: List = listOf( - anAccountProvider() - ), - canSearchForAccountProviders: Boolean = true, - changeServerState: ChangeServerState = aChangeServerState(), -) = ChangeAccountProviderState( - accountProviders = accountProviders.toImmutableList(), - canSearchForAccountProviders = canSearchForAccountProviders, - changeServerState = changeServerState, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt deleted file mode 100644 index 212bab515d..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView -import io.element.android.features.login.impl.accountprovider.AccountProviderView -import io.element.android.features.login.impl.changeserver.ChangeServerEvents -import io.element.android.features.login.impl.changeserver.ChangeServerView -import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule -import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding -import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 - */ -@Composable -fun ChangeAccountProviderView( - state: ChangeAccountProviderState, - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onSuccess: () -> Unit, - onOtherProviderClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = {}, - navigationIcon = { BackButton(onClick = onBackClick) } - ) - }, - contentWindowInsets = scaffoldScrollableContentInsets, - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(padding) - .consumeWindowInsets(padding) - ) { - Column( - modifier = Modifier - .verticalScroll(state = rememberScrollState()) - .padding(lazyColumnContentPadding) - ) { - IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), - iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), - title = stringResource(id = R.string.screen_change_account_provider_title), - subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle), - ) - - state.accountProviders.forEach { item -> - val alteredItem = if (item.isMatrixOrg) { - // Set the subtitle from the resource - item.copy( - subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle), - ) - } else { - item - } - AccountProviderView( - item = alteredItem, - onClick = { - state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem)) - } - ) - } - // Other - if (state.canSearchForAccountProviders) { - AccountProviderOtherView( - onClick = onOtherProviderClick - ) - } - Spacer(Modifier.height(32.dp)) - } - ChangeServerView( - state = state.changeServerState, - onLearnMoreClick = onLearnMoreClick, - onSuccess = onSuccess, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview { - ChangeAccountProviderView( - state = state, - onBackClick = { }, - onLearnMoreClick = { }, - onSuccess = { }, - onOtherProviderClick = { }, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt deleted file mode 100644 index 8816de3946..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -sealed interface SearchAccountProviderEvents { - /** - * The user has typed something, expect to get a list of matching account provider results - * in the state. - */ - data class UserInput(val input: String) : SearchAccountProviderEvents -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt deleted file mode 100644 index ddbcc4c847..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.login.impl.util.openLearnMorePage -import io.element.android.libraries.architecture.callback - -@ContributesNode(AppScope::class) -@AssistedInject -class SearchAccountProviderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - private val presenter: SearchAccountProviderPresenter, -) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { - fun onDone() - } - - private val callback: Callback = callback() - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - val context = LocalContext.current - SearchAccountProviderView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onLearnMoreClick = { openLearnMorePage(context) }, - onSuccess = callback::onDone, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt deleted file mode 100644 index 2efe4501c6..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import dev.zacsweers.metro.Inject -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.features.login.impl.resolver.HomeserverResolver -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.architecture.Presenter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Inject -class SearchAccountProviderPresenter( - private val homeserverResolver: HomeserverResolver, - private val changeServerPresenter: Presenter, -) : Presenter { - @Composable - override fun present(): SearchAccountProviderState { - var userInput by rememberSaveable { - mutableStateOf("") - } - val changeServerState = changeServerPresenter.present() - - val data: MutableState>> = remember { - mutableStateOf(AsyncData.Uninitialized) - } - - LaunchedEffect(userInput) { - onUserInput(userInput, data) - } - - fun handleEvent(event: SearchAccountProviderEvents) { - when (event) { - is SearchAccountProviderEvents.UserInput -> { - userInput = event.input - } - } - } - - return SearchAccountProviderState( - userInput = userInput, - userInputResult = data.value, - changeServerState = changeServerState, - eventSink = ::handleEvent, - ) - } - - private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch { - data.value = AsyncData.Uninitialized - // Debounce - delay(500) - data.value = AsyncData.Loading() - homeserverResolver.resolve(userInput).collect { - data.value = AsyncData.Success(it) - } - if (data.value !is AsyncData.Success) { - data.value = AsyncData.Uninitialized - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt deleted file mode 100644 index dc02a997a6..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData - -data class SearchAccountProviderState( - val userInput: String, - val userInputResult: AsyncData>, - val changeServerState: ChangeServerState, - val eventSink: (SearchAccountProviderEvents) -> Unit -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt deleted file mode 100644 index 2a8512c38a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData - -open class SearchAccountProviderStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aSearchAccountProviderState(), - aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())), - // Add other state here - ) -} - -fun aSearchAccountProviderState( - userInput: String = "", - userInputResult: AsyncData> = AsyncData.Uninitialized, -) = SearchAccountProviderState( - userInput = userInput, - userInputResult = userInputResult, - changeServerState = aChangeServerState(), - eventSink = {} -) - -fun aHomeserverDataList(): List { - return listOf( - aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL), - aHomeserverData(homeserverUrl = "https://no.sliding.sync"), - aHomeserverData(homeserverUrl = "https://invalid"), - ) -} - -fun aHomeserverData( - homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, -): HomeserverData { - return HomeserverData(homeserverUrl = homeserverUrl) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt deleted file mode 100644 index 8335dde112..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.AccountProviderView -import io.element.android.features.login.impl.changeserver.ChangeServerEvents -import io.element.android.features.login.impl.changeserver.ChangeServerView -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule -import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.form.textFieldState -import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.TextField -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.ui.strings.CommonStrings - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435 - */ -@Composable -fun SearchAccountProviderView( - state: SearchAccountProviderState, - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onSuccess: () -> Unit, - modifier: Modifier = Modifier, -) { - val eventSink = state.eventSink - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = {}, - navigationIcon = { BackButton(onClick = onBackClick) } - ) - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(padding) - .consumeWindowInsets(padding) - ) { - LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { - item { - IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp), - iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), - title = stringResource(id = R.string.screen_account_provider_form_title), - subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle), - ) - } - item { - // TextInput - var userInputState by textFieldState(stateValue = state.userInput) - val focusManager = LocalFocusManager.current - TextField( - value = userInputState, - // readOnly = isLoading, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .onTabOrEnterKeyFocusNext(focusManager) - .testTag(TestTags.changeServerServer), - onValueChange = { - userInputState = it - eventSink(SearchAccountProviderEvents.UserInput(it)) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { - focusManager.moveFocus(FocusDirection.Down) - }), - singleLine = true, - trailingIcon = if (userInputState.isNotEmpty()) { - { - Box( - Modifier.clickable( - onClickLabel = stringResource(CommonStrings.action_clear), - role = Role.Button, - ) { - userInputState = "" - eventSink(SearchAccountProviderEvents.UserInput("")) - } - ) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_clear) - ) - } - } - } else { - null - }, - supportingText = stringResource(id = R.string.screen_account_provider_form_notice), - ) - } - - when (state.userInputResult) { - is AsyncData.Failure -> { - // Ignore errors (let the user type more chars) - } - is AsyncData.Loading -> { - item { - Box( - modifier = Modifier - .fillMaxSize() - ) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) - } - } - } - is AsyncData.Success -> { - items(state.userInputResult.data) { homeserverData -> - val item = homeserverData.toAccountProvider() - AccountProviderView( - item = item, - onClick = { - state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item)) - } - ) - } - } - AsyncData.Uninitialized -> Unit - } - item { - Spacer(Modifier.height(32.dp)) - } - } - ChangeServerView( - state = state.changeServerState, - onLearnMoreClick = onLearnMoreClick, - onSuccess = onSuccess, - ) - } - } -} - -@Composable -private fun HomeserverData.toAccountProvider(): AccountProvider { - val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL - return AccountProvider( - url = homeserverUrl, - subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, - // There is no need to know for other servers right now - isPublic = isMatrixOrg, - isMatrixOrg = isMatrixOrg, - ) -} - -@PreviewsDayNight -@Composable -internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview { - SearchAccountProviderView( - state = state, - onBackClick = {}, - onLearnMoreClick = {}, - onSuccess = {}, - ) -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt deleted file mode 100644 index 7d81f05c0c..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER -import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.test -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class ChangeAccountProviderPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { emptyList() } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isTrue() - } - } - - @Test - fun `present - fixed list of account providers`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { - listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2) - } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ), - AccountProvider( - url = "https://element.io", - title = "element.io", - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isFalse() - } - } - - @Test - fun `present - opened list of account providers`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { - listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER) - } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isTrue() - } - } -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt deleted file mode 100644 index 29c5d4a86c..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverResolver -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker -import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor -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 io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class SearchAccountProviderPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.userInput).isEmpty() - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - } - } - - @Test - fun `present - error while checking login compatibility`() = runTest { - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("https://test.org") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org") - ) - ) - ) - } - } - - @Test - fun `present - enter text no result`() = runTest { - val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test"), - ) - ) - ) - } - } - - @Test - fun `present - enter text one result with wellknown`() = runTest { - val checkResult = lambdaRecorder> { - when (it) { - "https://test.org" -> Result.success(false) - "https://test.com" -> Result.success(false) - "https://test.io" -> Result.success(true) - "https://test" -> Result.success(false) - else -> error("should not happen") - } - } - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.io") - ) - ) - ) - checkResult.assertions().isCalledExactly(4) - .withSequence( - listOf(value("https://test.org")), - listOf(value("https://test.com")), - listOf(value("https://test.io")), - listOf(value("https://test")), - ) - } - } - - @Test - fun `present - enter text two results with wellknown`() = runTest { - val checkResult = lambdaRecorder> { - when (it) { - "https://test.org" -> Result.success(true) - "https://test.com" -> Result.success(false) - "https://test.io" -> Result.success(true) - "https://test" -> Result.success(false) - else -> error("should not happen") - } - } - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org"), - ) - ) - ) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org"), - aHomeserverData(homeserverUrl = "https://test.io"), - ) - ) - ) - checkResult.assertions().isCalledExactly(4) - .withSequence( - listOf(value("https://test.org")), - listOf(value("https://test.com")), - listOf(value("https://test.io")), - listOf(value("https://test")), - ) - } - } -} From d04a3853b4822bb3c12a10c3e6b86dbd23aefe01 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 6 Aug 2026 12:11:18 -0700 Subject: [PATCH 008/118] Add inline autocomplete for the account provider field --- .../ConfirmAccountProviderPresenter.kt | 23 ++++++++-- .../ConfirmAccountProviderState.kt | 2 + .../ConfirmAccountProviderStateProvider.kt | 6 +++ .../ConfirmAccountProviderView.kt | 44 ++++++++++++++++++ .../ConfirmAccountProviderPresenterTest.kt | 46 +++++++++++++++++++ .../src/main/res/values/temporary.xml | 1 + 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index de6ec28774..c3f51a5177 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -27,6 +28,7 @@ import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.preferences.api.store.AppPreferencesStore @AssistedInject class ConfirmAccountProviderPresenter( @@ -34,6 +36,7 @@ class ConfirmAccountProviderPresenter( private val accountProviderDataSource: AccountProviderDataSource, private val loginModePresenter: Presenter, private val changeServerPresenter: Presenter, + private val appPreferencesStore: AppPreferencesStore, ) : Presenter { data class Params( val isAccountCreation: Boolean, @@ -49,11 +52,24 @@ class ConfirmAccountProviderPresenter( val accountProvider by accountProviderDataSource.flow.collectAsState() val loginModeState = loginModePresenter.present() val changeServerState = changeServerPresenter.present() + val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. var userInput by rememberSaveable { mutableStateOf(null) } val accountProviderInput = userInput ?: accountProvider.url - val latestAccountProviderInput by rememberUpdatedState(accountProviderInput) + + // Offer the most recent previously-used account provider that the current input is a prefix of. + val accountProviderSuggestion = remember(accountProviderInput, homeserverHistory) { + val input = accountProviderInput.trim() + input.takeIf { it.isNotEmpty() } + ?.let { prefix -> + homeserverHistory.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } + } + } + + // Continue accepts the suggestion when one is offered, otherwise the raw input. + val accountProviderToSubmit = accountProviderSuggestion ?: accountProviderInput + val latestAccountProviderToSubmit by rememberUpdatedState(accountProviderToSubmit) // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. LaunchedEffect(changeServerState.changeServerAction) { @@ -61,7 +77,7 @@ class ConfirmAccountProviderPresenter( loginModeState.eventSink( LoginModeEvent.Submit( isAccountCreation = params.isAccountCreation, - homeserverUrl = latestAccountProviderInput.trim(), + homeserverUrl = latestAccountProviderToSubmit.trim(), resolvedHomeserverUrl = null, loginHint = null, ) @@ -77,7 +93,7 @@ class ConfirmAccountProviderPresenter( // Validate (and persist) the chosen account provider before proceeding. This also enforces the // enterprise account-provider access control, which the login submit does not do on its own. ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink( - ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderInput.trim())) + ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim())) ) ConfirmAccountProviderEvents.ClearError -> { loginModeState.eventSink(LoginModeEvent.ClearError) @@ -88,6 +104,7 @@ class ConfirmAccountProviderPresenter( return ConfirmAccountProviderState( accountProviderInput = accountProviderInput, + accountProviderSuggestion = accountProviderSuggestion, isAccountCreation = params.isAccountCreation, loginModeState = loginModeState, changeServerState = changeServerState, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt index 0c211341ef..a934378507 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.architecture.AsyncData data class ConfirmAccountProviderState( val accountProviderInput: String, + // The full account provider from history that the current input is a prefix of, offered as inline autocomplete. + val accountProviderSuggestion: String?, val isAccountCreation: Boolean, val loginModeState: LoginModeState, val changeServerState: ChangeServerState, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt index 73c5e5f550..53fa763c2f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt @@ -20,6 +20,10 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider get() = sequenceOf( aConfirmAccountProviderState(), + aConfirmAccountProviderState( + accountProviderInput = "element", + accountProviderSuggestion = "element.io", + ), aConfirmAccountProviderState( isAccountCreation = true, ), @@ -32,12 +36,14 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider Unit = {}, ) = ConfirmAccountProviderState( accountProviderInput = accountProviderInput, + accountProviderSuggestion = accountProviderSuggestion, isAccountCreation = isAccountCreation, loginModeState = loginModeState, changeServerState = changeServerState, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 3a7d2831c8..470b59926b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -16,15 +16,25 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.login.LoginModeEvent @@ -89,6 +99,15 @@ fun ConfirmAccountProviderView( ) { var input by textFieldState(stateValue = state.accountProviderInput) val focusManager = LocalFocusManager.current + // Inline autocomplete: render the un-typed remainder of the suggested account provider in grey. + val suggestionSuffix = state.accountProviderSuggestion + ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } + ?.substring(input.length) + .orEmpty() + val ghostColor = ElementTheme.colors.textSecondary + val ghostTransformation = remember(suggestionSuffix, ghostColor) { + GhostSuffixVisualTransformation(suggestionSuffix, ghostColor) + } TextField( value = input, onValueChange = { @@ -101,6 +120,7 @@ fun ConfirmAccountProviderView( .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.changeServerServer), label = stringResource(id = CommonStrings.screen_select_server_textfield_header), + placeholder = stringResource(id = CommonStrings.screen_select_server_textfield_placeholder), supportingText = stringResource( id = if (state.isAccountCreation) { CommonStrings.screen_select_server_textfield_footer_register @@ -108,6 +128,7 @@ fun ConfirmAccountProviderView( CommonStrings.screen_select_server_textfield_footer_login } ), + visualTransformation = ghostTransformation, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done, @@ -167,6 +188,29 @@ fun ConfirmAccountProviderView( ) } +/** + * Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint. + * The underlying field value stays limited to what the user actually typed, so the cursor can never + * enter the suggested region. + */ +private class GhostSuffixVisualTransformation( + private val suffix: String, + private val color: Color, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity) + val transformed = buildAnnotatedString { + append(text) + withStyle(SpanStyle(color = color)) { append(suffix) } + } + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length) + } + return TransformedText(transformed, offsetMapping) + } +} + @PreviewsDayNight @Composable internal fun ConfirmAccountProviderViewPreview( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 0257d68ea9..eba6a4474f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -34,6 +34,8 @@ import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest @@ -353,6 +355,48 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - offers an autocomplete suggestion from the account provider history`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://randomcommunity.org"), + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviderSuggestion).isNull() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) + val suggestionState = awaitState { it.accountProviderInput == "https://random" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("https://randomcommunity.org") + } + } + + @Test + fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://randomcommunity.org"), + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) + val suggestionState = awaitState { it.accountProviderSuggestion != null } + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") + cancelAndIgnoreRemainingEvents() + } + } + /** * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate * account-provider validation states, and returns that state. @@ -376,9 +420,11 @@ class ConfirmAccountProviderPresenterTest { matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), ) = ConfirmAccountProviderPresenter( params = params, accountProviderDataSource = accountProviderDataSource, + appPreferencesStore = appPreferencesStore, loginModePresenter = createLoginModePresenter( authenticationService = matrixAuthenticationService, oAuthActionFlow = defaultOAuthActionFlow, diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index ca01aead32..2b5788042e 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -13,6 +13,7 @@ "Enter your account provider" "Choose an account provider" "Account provider" + "example.com" "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." From 8c2cf9d95e6e849e2c366c98a73768bbae7d3514 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Fri, 7 Aug 2026 08:41:59 -0700 Subject: [PATCH 009/118] Reference the Figma design on the account provider screen --- .../ConfirmAccountProviderPresenter.kt | 2 +- .../confirmaccountprovider/ConfirmAccountProviderView.kt | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index c3f51a5177..f83bce0510 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -91,7 +91,7 @@ class ConfirmAccountProviderPresenter( userInput = event.accountProvider } // Validate (and persist) the chosen account provider before proceeding. This also enforces the - // enterprise account-provider access control, which the login submit does not do on its own. + // account-provider access control, which the login submit does not run on its own. ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink( ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim())) ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 470b59926b..8be8bab7e8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -56,6 +56,12 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +/** + * Account provider entry screen, shared by sign in and account creation. + * Figma (Compound Android Templates): + * - Sign in: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10251 + * - Create account: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10239 + */ @Composable fun ConfirmAccountProviderView( state: ConfirmAccountProviderState, From d7e6b64265ceae0549dfe206eeb70e17979b75e4 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Fri, 7 Aug 2026 12:46:11 -0700 Subject: [PATCH 010/118] Dismiss the keyboard on continue and restore it after an error --- .../ConfirmAccountProviderState.kt | 4 +++ .../ConfirmAccountProviderView.kt | 33 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt index a934378507..bf6b0cb0e7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -29,4 +29,8 @@ data class ConfirmAccountProviderState( val isLoading: Boolean get() = loginModeState.loginMode is AsyncData.Loading || changeServerState.changeServerAction is AsyncData.Loading + + val isShowingError: Boolean + get() = loginModeState.loginMode is AsyncData.Failure || + changeServerState.changeServerAction is AsyncData.Failure } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 8be8bab7e8..e1be11da3a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -15,12 +15,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString @@ -72,6 +77,26 @@ fun ConfirmAccountProviderView( modifier: Modifier = Modifier, ) { val eventSink = state.eventSink + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + fun submit() { + // Dismiss the keyboard and release focus while the account provider is being validated. + focusManager.clearFocus(force = true) + eventSink(ConfirmAccountProviderEvents.Continue) + } + + // Once a validation / login error has been dismissed, return focus and the keyboard to the field so + // the user can correct the account provider and retry. + var wasShowingError by remember { mutableStateOf(false) } + LaunchedEffect(state.isShowingError) { + if (wasShowingError && !state.isShowingError) { + focusRequester.requestFocus() + keyboardController?.show() + } + wasShowingError = state.isShowingError + } HeaderFooterPage( modifier = modifier, @@ -94,7 +119,7 @@ fun ConfirmAccountProviderView( Button( text = stringResource(id = CommonStrings.action_continue), showProgress = state.isLoading, - onClick = { eventSink(ConfirmAccountProviderEvents.Continue) }, + onClick = ::submit, enabled = state.submitEnabled && !state.isLoading, modifier = Modifier .fillMaxWidth() @@ -104,7 +129,6 @@ fun ConfirmAccountProviderView( } ) { var input by textFieldState(stateValue = state.accountProviderInput) - val focusManager = LocalFocusManager.current // Inline autocomplete: render the un-typed remainder of the suggested account provider in grey. val suggestionSuffix = state.accountProviderSuggestion ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } @@ -123,6 +147,7 @@ fun ConfirmAccountProviderView( modifier = Modifier .fillMaxWidth() .padding(top = 40.dp) + .focusRequester(focusRequester) .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.changeServerServer), label = stringResource(id = CommonStrings.screen_select_server_textfield_header), @@ -139,9 +164,7 @@ fun ConfirmAccountProviderView( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done, ), - keyboardActions = KeyboardActions(onDone = { - eventSink(ConfirmAccountProviderEvents.Continue) - }), + keyboardActions = KeyboardActions(onDone = { submit() }), singleLine = true, trailingIcon = if (input.isNotEmpty()) { { From f8816b2449416ef998a396d5f59fc9f83c96e0c5 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 10 Aug 2026 09:28:31 -0700 Subject: [PATCH 011/118] Submit the exact account provider field text on continue --- .../ConfirmAccountProviderEvents.kt | 5 +++- .../ConfirmAccountProviderPresenter.kt | 18 +++++++----- .../ConfirmAccountProviderView.kt | 18 +++++++----- .../ConfirmAccountProviderPresenterTest.kt | 28 +++++++++---------- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt index 1fb024156b..9093a8121f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -10,6 +10,9 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider sealed interface ConfirmAccountProviderEvents { data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents - data object Continue : ConfirmAccountProviderEvents + + // Carries the account provider to submit (the current field text, or the accepted autocomplete suggestion), + // captured from the view at click time so it can't lag behind fast input (e.g. keyboard autofill). + data class Continue(val accountProvider: String) : ConfirmAccountProviderEvents data object ClearError : ConfirmAccountProviderEvents } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index f83bce0510..fbadcddc15 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -67,9 +67,10 @@ class ConfirmAccountProviderPresenter( } } - // Continue accepts the suggestion when one is offered, otherwise the raw input. - val accountProviderToSubmit = accountProviderSuggestion ?: accountProviderInput - val latestAccountProviderToSubmit by rememberUpdatedState(accountProviderToSubmit) + // The account provider submitted via Continue (carried by the event, so it reflects the exact field + // text at click time); used to actually sign in once validation succeeds. + var submittedAccountProvider by remember { mutableStateOf(null) } + val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider) // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. LaunchedEffect(changeServerState.changeServerAction) { @@ -77,7 +78,7 @@ class ConfirmAccountProviderPresenter( loginModeState.eventSink( LoginModeEvent.Submit( isAccountCreation = params.isAccountCreation, - homeserverUrl = latestAccountProviderToSubmit.trim(), + homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(), resolvedHomeserverUrl = null, loginHint = null, ) @@ -92,9 +93,12 @@ class ConfirmAccountProviderPresenter( } // Validate (and persist) the chosen account provider before proceeding. This also enforces the // account-provider access control, which the login submit does not run on its own. - ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink( - ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim())) - ) + is ConfirmAccountProviderEvents.Continue -> { + submittedAccountProvider = event.accountProvider + changeServerState.eventSink( + ChangeServerEvents.ChangeServer(AccountProvider(url = event.accountProvider.trim())) + ) + } ConfirmAccountProviderEvents.ClearError -> { loginModeState.eventSink(LoginModeEvent.ClearError) changeServerState.eventSink(ChangeServerEvents.ClearError) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index e1be11da3a..385a8477f6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -81,10 +81,20 @@ fun ConfirmAccountProviderView( val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current + var input by textFieldState(stateValue = state.accountProviderInput) + // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and + // accepted on Continue). Only valid while the suggestion still starts with the current input. + val suggestionSuffix = state.accountProviderSuggestion + ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } + ?.substring(input.length) + .orEmpty() + fun submit() { // Dismiss the keyboard and release focus while the account provider is being validated. focusManager.clearFocus(force = true) - eventSink(ConfirmAccountProviderEvents.Continue) + // Submit the exact field text (plus any accepted suggestion), read here so it can't lag behind + // fast input such as keyboard autofill. + eventSink(ConfirmAccountProviderEvents.Continue(input + suggestionSuffix)) } // Once a validation / login error has been dismissed, return focus and the keyboard to the field so @@ -128,12 +138,6 @@ fun ConfirmAccountProviderView( } } ) { - var input by textFieldState(stateValue = state.accountProviderInput) - // Inline autocomplete: render the un-typed remainder of the suggested account provider in grey. - val suggestionSuffix = state.accountProviderSuggestion - ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } - ?.substring(input.length) - .orEmpty() val ghostColor = ElementTheme.colors.textSecondary val ghostTransformation = remember(suggestionSuffix, ghostColor) { GhostSuffixVisualTransformation(suggestionSuffix, ghostColor) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index eba6a4474f..b3bf502965 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -70,7 +70,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) cancelAndIgnoreRemainingEvents() @@ -89,7 +89,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) cancelAndIgnoreRemainingEvents() @@ -110,7 +110,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenOAuthCancelError(AN_EXCEPTION) @@ -135,7 +135,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOAuthActionFlow.post(OAuthAction.GoBack()) @@ -159,7 +159,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) awaitLoginMode { it is AsyncData.Loading } defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } @@ -182,7 +182,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenLoginError(AN_EXCEPTION) @@ -207,7 +207,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) @@ -229,7 +229,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // The account provider validation fails, so the login is never attempted. val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) @@ -251,7 +251,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() // Submit will return an error while validating the account provider - initialState.eventSink(ConfirmAccountProviderEvents.Continue) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // Check an error was returned val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } @@ -279,7 +279,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // Check an error was returned val submittedState = awaitLoginMode { it is AsyncData.Failure } assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) @@ -304,7 +304,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) cancelAndIgnoreRemainingEvents() @@ -326,7 +326,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) cancelAndIgnoreRemainingEvents() @@ -348,7 +348,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl)) cancelAndIgnoreRemainingEvents() @@ -390,7 +390,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) val suggestionState = awaitState { it.accountProviderSuggestion != null } - suggestionState.eventSink(ConfirmAccountProviderEvents.Continue) + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("https://randomcommunity.org")) awaitLoginMode { it is AsyncData.Success } assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") cancelAndIgnoreRemainingEvents() From bbb3a6808761ab450a605008f50ec86609927f8b Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 10 Aug 2026 09:28:32 -0700 Subject: [PATCH 012/118] Save the selected account provider to history even if the flow default is reset --- .../accountprovider/AccountProviderDataSource.kt | 7 +++++++ .../SaveAccountProviderToHistory.kt | 6 +++++- .../SaveAccountProviderToHistoryTest.kt | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt index 28e4294fa4..809c835d65 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -43,6 +43,12 @@ class AccountProviderDataSource( val flow: StateFlow = accountProvider.asStateFlow() + // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]). + // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a + // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip). + var lastSelectedAccountProviderUrl: String? = null + private set + init { // Seed the default from the last used provider, unless the user has already selected one. coroutineScope.launch { @@ -72,6 +78,7 @@ class AccountProviderDataSource( } suspend fun setAccountProvider(data: AccountProvider) { + lastSelectedAccountProviderUrl = data.url accountProvider.emit(data) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt index a88be1ae15..46f3281826 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt @@ -23,6 +23,10 @@ class SaveAccountProviderToHistory( private val appPreferencesStore: AppPreferencesStore, ) { suspend operator fun invoke() { - appPreferencesStore.addHomeserverToHistory(accountProviderDataSource.flow.value.url) + // Persist the provider the user actually selected, not the currently-exposed default: the latter can be + // reset back to the history default while the login flow is torn down across an OAuth round-trip. + val url = accountProviderDataSource.lastSelectedAccountProviderUrl + ?: accountProviderDataSource.flow.value.url + appPreferencesStore.addHomeserverToHistory(url) } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt index 957235395f..21fd7efce8 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt @@ -31,4 +31,20 @@ class SaveAccountProviderToHistoryTest { assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) .containsExactly("https://example.com") } + + @Test + fun `invoke saves the selected provider even after the exposed default has been reset`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val accountProviderDataSource = anAccountProviderDataSource(appPreferencesStore = appPreferencesStore) + accountProviderDataSource.setUrl("https://example.com") + // Simulate the login flow being torn down (e.g. across an OAuth round-trip), which reverts the + // exposed default; the user's selection must still be the one saved to history. + accountProviderDataSource.reset() + val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore) + + sut() + + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com") + } } From 749c96e409b9bac3da3ed9e2902b0eac5c3481ad Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 10 Aug 2026 09:42:44 -0700 Subject: [PATCH 013/118] Raise the account provider history cap to 20 entries --- .../preferences/impl/store/DefaultAppPreferencesStore.kt | 2 +- .../impl/store/DefaultAppPreferencesStoreTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index f8db0c11ee..1a14a0ea8f 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -47,7 +47,7 @@ private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallR // URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String. private const val HOMESERVER_HISTORY_DELIMITER = "\n" -private const val MAX_HOMESERVER_HISTORY_SIZE = 10 +private const val MAX_HOMESERVER_HISTORY_SIZE = 20 @ContributesBinding(AppScope::class) class DefaultAppPreferencesStore( diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt index fbb9005ea5..4cf018371a 100644 --- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt +++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt @@ -110,13 +110,13 @@ class DefaultAppPreferencesStoreTest { fun `homeserver history is capped to the most recent entries`() = runTest { val store = createStore() - repeat(15) { index -> + repeat(25) { index -> store.addHomeserverToHistory("https://server$index.org") } val history = store.getHomeserverHistoryFlow().first() - assertThat(history).hasSize(10) - assertThat(history.first()).isEqualTo("https://server14.org") + assertThat(history).hasSize(20) + assertThat(history.first()).isEqualTo("https://server24.org") assertThat(history.last()).isEqualTo("https://server5.org") } From ea076ecc4bddd880ee8019709115454852653f9e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:48:17 +0000 Subject: [PATCH 014/118] Update media3 to v1.11.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17eb8ff751..d1a777d3b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" -media3 = "1.10.1" +media3 = "1.11.0" camera = "1.6.1" work = "2.11.2" From 67b48ae184556f1600964b436a28f1012c307951 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:55:53 +0000 Subject: [PATCH 015/118] Fix build failure --- .../impl/pinned/list/PinnedMessagesListNode.kt | 3 +++ .../impl/pinned/list/PinnedMessagesListView.kt | 11 ++++++++++- .../pinned/list/PinnedMessagesListPresenterTest.kt | 4 ++++ .../impl/pinned/list/PinnedMessagesListViewTest.kt | 2 ++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 06d97f167c..36ae68de9b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId @@ -50,6 +51,7 @@ class PinnedMessagesListNode( actionListPresenterFactory: ActionListPresenter.Factory, private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val permalinkParser: PermalinkParser, + private val emojiPickerRenderer: EmojiPickerRenderer, ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) @@ -134,6 +136,7 @@ class PinnedMessagesListNode( toastMessage = toastMessage, ) }, + emojiPickerRenderer = emojiPickerRenderer, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index f5d4b402d0..0b76d35d36 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -54,6 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets +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.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.ui.strings.CommonStrings @@ -70,6 +72,7 @@ fun PinnedMessagesListView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { Scaffold( @@ -93,6 +96,7 @@ fun PinnedMessagesListView( onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, onErrorDismiss = onBackClick, + emojiPickerRenderer = emojiPickerRenderer, modifier = Modifier .padding(padding) .consumeWindowInsets(padding), @@ -125,6 +129,7 @@ private fun PinnedMessagesListContent( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onErrorDismiss: () -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { Box(modifier.fillMaxSize()) { @@ -145,6 +150,7 @@ private fun PinnedMessagesListContent( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, + emojiPickerRenderer = emojiPickerRenderer, ) PinnedMessagesListState.Loading -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -184,6 +190,7 @@ private fun PinnedMessagesListLoaded( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { @@ -295,7 +302,8 @@ private fun PinnedMessagesListLoaded( state = state.customReactionState, onSelectEmoji = { eventOrTransactionId, emoji -> state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId)) - } + }, + emojiPickerRenderer = emojiPickerRenderer, ) ReactionSummaryView(state = state.reactionSummaryState) } @@ -347,5 +355,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS onUserDataClick = {}, onLinkClick = {}, onLinkLongClick = {}, + emojiPickerRenderer = NoOpEmojiPickerRenderer, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 3efa707c3d..cafb16c41f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -400,6 +402,8 @@ class PinnedMessagesListPresenterTest { snackbarDispatcher = SnackbarDispatcher(), actionListPresenter = { anActionListState() }, linkPresenter = { aLinkState() }, + customReactionPresenter = { aCustomReactionState() }, + reactionSummaryPresenter = { aReactionSummaryState() }, analyticsService = analyticsService, featureFlagService = featureFlagService, sessionCoroutineScope = this, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt index 35e60684a9..856f74e33a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam @@ -111,6 +112,7 @@ private fun AndroidComposeUiTest.setPinnedMessagesListView( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, + emojiPickerRenderer = NoOpEmojiPickerRenderer, ) } } From 54ba43a8ec5646f73bfd903059745b014d3b5695 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Fri, 14 Aug 2026 08:12:51 -0700 Subject: [PATCH 016/118] Use the correct license header on the new account provider history files --- .../login/impl/accountprovider/SaveAccountProviderToHistory.kt | 2 +- .../impl/accountprovider/AccountProviderDataSourceFixture.kt | 2 +- .../impl/accountprovider/SaveAccountProviderToHistoryTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt index 46f3281826..b3b99115cc 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt index e8f9dbefb9..25bc67144b 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt index 21fd7efce8..0af8188f3f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. From f488da890b57c94f4ba6492f1f21d9b12289d300 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Fri, 14 Aug 2026 08:57:37 -0700 Subject: [PATCH 017/118] Update the maestro sign-in flow for the consolidated account provider screen --- .maestro/tests/account/changeServer.yaml | 21 ------------------- .maestro/tests/account/login.yaml | 3 +-- .../assertions/assertLoginDisplayed.yaml | 2 +- 3 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 .maestro/tests/account/changeServer.yaml diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml deleted file mode 100644 index b07fa5cc09..0000000000 --- a/.maestro/tests/account/changeServer.yaml +++ /dev/null @@ -1,21 +0,0 @@ -appId: ${MAESTRO_APP_ID} ---- -- tapOn: - id: "login-change_server" -- takeScreenshot: build/maestro/200-ChangeServer -- tapOn: "matrix.org" -- tapOn: - id: "login-change_server" -- tapOn: "Other" -- tapOn: - id: "change_server-server" -- inputText: "element" -- hideKeyboard -- extendedWaitUntil: - visible: "element.io" - timeout: 10000 -- tapOn: "element.io" -# Revert to matrix.org -- tapOn: - id: "login-change_server" -- tapOn: "matrix.org" diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 873843b22d..81922c7f38 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID} - tapOn: "Sign in manually" - runFlow: ../assertions/assertLoginDisplayed.yaml - takeScreenshot: build/maestro/100-SignIn -- runFlow: changeServer.yaml -- runFlow: ../assertions/assertLoginDisplayed.yaml +# The account provider field defaults to matrix.org, so continue straight to the MAS login. - tapOn: id: "login-continue" ## MAS page diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml index 6d8558c38e..c32f23c323 100644 --- a/.maestro/tests/assertions/assertLoginDisplayed.yaml +++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml @@ -1,5 +1,5 @@ appId: ${MAESTRO_APP_ID} --- - extendedWaitUntil: - visible: "Change account provider" + visible: "Enter your account provider" timeout: 10000 From 2c0da1cadf13bbb4a08d0151c2342e0e7aa52b2b Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:44:11 +0300 Subject: [PATCH 018/118] Share a file whose mime type is not one of the supported ones handleIncomingShareIntent only accepted an intent whose resolved mime type started with image/, video/, audio/, application/, file/, text/ or */. An ACTION_SEND carrying EXTRA_STREAM with any other top level type, such as the message/rfc822 that some apps use when attaching a file, fell through to null and the share was dropped before the room picker ever appeared, even though the manifest filter accepts */*. The uris are extracted before that check, so their presence is enough to know the intent carries something to send. Accept the intent when it has uris and keep the mime type list for the case where it does not. --- .../share/impl/DefaultShareIntentHandler.kt | 3 +- .../impl/DefaultShareIntentHandlerTest.kt | 112 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt index cfa06c3a97..2ae31fb00c 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt @@ -42,7 +42,8 @@ class DefaultShareIntentHandler( val uris = getIncomingUris(intent, type) return when { uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent) - type.isMimeTypeImage() || + uris.isNotEmpty() || + type.isMimeTypeImage() || type.isMimeTypeVideo() || type.isMimeTypeAudio() || type.isMimeTypeApplication() || diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt new file mode 100644 index 0000000000..8cde31bd0c --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment + +class DefaultShareIntentHandlerTest : RobolectricTest() { + @Test + fun `an image is shared as an uri`() { + val uri = "content://sender/image.jpg".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "image/jpeg", uri = uri) + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "image/jpeg")), + ) + ) + } + + @Test + fun `an uri with an unlisted mime type is shared`() { + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "message/rfc822", uri = uri) + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + @Test + fun `an uri with an unlisted mime type keeps the text as a caption`() { + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "message/rfc822", uri = uri).apply { + putExtra(Intent.EXTRA_TEXT, "a caption") + } + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = "a caption", + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + @Test + fun `several uris with an unlisted mime type are shared`() { + val firstUri = "content://sender/first.zip".toUri() + val secondUri = "content://sender/second.zip".toUri() + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "message/rfc822" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(firstUri, secondUri)) + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf( + UriToShare(uri = firstUri, mimeType = "message/rfc822"), + UriToShare(uri = secondUri, mimeType = "message/rfc822"), + ), + ) + ) + } + + @Test + fun `a plain text is shared as a text`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "a text") + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo(ShareIntentData.PlainText("a text")) + } + + @Test + fun `an intent with neither uri nor text is not handled`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isNull() + } + + private fun aSendIntent(type: String, uri: Uri) = Intent(Intent.ACTION_SEND).apply { + this.type = type + putExtra(Intent.EXTRA_STREAM, uri) + } + + private fun createDefaultShareIntentHandler() = DefaultShareIntentHandler( + context = RuntimeEnvironment.getApplication(), + ) +} From 39afaa1fe46b5fe65cdb8e7e4b8b88afe04f6cca Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:44:53 +0300 Subject: [PATCH 019/118] Do not render in-chat key verification requests in the timeline A m.key.verification.request sent from a client that supports in-chat verification carries a plain text body for clients that do not. Element X has no msgtype of its own for it, so it fell through to OtherMessageType and the timeline printed that body, telling the user their client does not support in-chat key verification right after a verification they had just completed. Drop the event when building the timeline items, as Element X iOS does. --- .../factories/TimelineItemsFactory.kt | 14 +++++- .../factories/TimelineItemsFactoryTest.kt | 46 +++++++++++++++++++ .../api/timeline/item/event/MessageType.kt | 9 +++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index 1679a6030a..9bf7aa3c32 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -21,6 +21,9 @@ import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -110,7 +113,11 @@ class TimelineItemsFactory( ): TimelineItem? { val timelineItem = when (val currentTimelineItem = timelineItems[index]) { - is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts) + is MatrixTimelineItem.Event -> if (currentTimelineItem.event.isKeyVerificationRequest()) { + null + } else { + eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts) + } is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) MatrixTimelineItem.Other -> null } @@ -118,3 +125,8 @@ class TimelineItemsFactory( return timelineItem } } + +private fun EventTimelineItem.isKeyVerificationRequest(): Boolean { + val messageType = (content as? MessageContent)?.type + return messageType is OtherMessageType && messageType.isKeyVerificationRequest +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt index 53c39ee530..f93c8f196e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt @@ -12,8 +12,10 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem @@ -82,6 +84,50 @@ class TimelineItemsFactoryTest { ).inOrder() } + @Test + fun `a key verification request is not rendered`() = runTest { + val factory = aTimelineItemsFactory( + config = TimelineItemsFactoryConfig( + computeReadReceipts = false, + computeReactions = false, + ) + ) + val items = listOf( + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-0"), + event = anEventTimelineItem( + sender = A_USER_ID, + content = aMessageContent( + body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.", + messageType = OtherMessageType( + msgType = "m.key.verification.request", + body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.", + ), + ), + ), + ), + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-1"), + event = anEventTimelineItem( + sender = A_USER_ID, + content = aMessageContent(body = "A regular message"), + ), + ), + ) + factory.timelineItems.test { + factory.replaceWith( + timelineItems = items, + roomMembers = emptyList(), + renderReadReceipts = false, + ) + val bodies = awaitItem() + .filterIsInstance() + .map { (it.content as TimelineItemTextContent).body } + assertThat(bodies).containsExactly("A regular message") + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun TestScope.groupPositionsOf(timestamps: List): List { val factory = aTimelineItemsFactory( config = TimelineItemsFactoryConfig( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index 2f61b8f58a..f2bd56ac57 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -128,4 +128,11 @@ sealed interface GalleryItemType { data class OtherMessageType( val msgType: String, val body: String, -) : MessageType +) : MessageType { + /** + * A `m.key.verification.request` carries a body meant for clients that cannot handle in-chat + * verification, so it must not be rendered as a message. + */ + val isKeyVerificationRequest: Boolean + get() = msgType == "m.key.verification.request" +} From 4c5baf8d6051d1d58117c993911ec4224827a830 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:01:05 +0300 Subject: [PATCH 020/118] Send a concrete mime type for media shared with a wildcard one Some apps share an image with the mime type image/* rather than a concrete one. That value was carried straight into the uploaded event, so the message reached the room with an unusable media type and bridges rejected it as unsupported. Resolve a wildcard subtype to a default for its top level type before processing the upload, reusing the mapping the download path already applies so the SDK can pick the right extension. --- .../libraries/core/mimetype/MimeTypes.kt | 13 +++++++ .../libraries/core/mimetype/MimeTypesTest.kt | 35 +++++++++++++++++++ .../matrix/impl/media/RustMediaLoader.kt | 13 ++----- .../impl/AndroidMediaPreProcessor.kt | 16 +++++---- .../impl/AndroidMediaPreProcessorTest.kt | 17 ++++++++- 5 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index e7962f8bd6..dee840ea48 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -63,4 +63,17 @@ object MimeTypes { val subType = components.last() return subType.isNotBlank() && subType != "*" } + + /** + * A mime type with a wildcard subtype is a filter, not a media type, so replace it with a + * concrete default for its top level type. + */ + fun String?.withDefaultSubtype(): String = when { + this == null -> OctetStream + hasSubtype(this) -> this + this == Images -> Jpeg + this == Videos -> Mp4 + this == Audio -> Mp3 + else -> OctetStream + } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt new file mode 100644 index 0000000000..7ba68fa9b1 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.mimetype + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes.withDefaultSubtype +import org.junit.Test + +class MimeTypesTest { + @Test + fun `withDefaultSubtype keeps a mime type that already has a subtype`() { + assertThat(MimeTypes.Png.withDefaultSubtype()).isEqualTo(MimeTypes.Png) + assertThat(MimeTypes.Mp4.withDefaultSubtype()).isEqualTo(MimeTypes.Mp4) + assertThat(MimeTypes.Pdf.withDefaultSubtype()).isEqualTo(MimeTypes.Pdf) + } + + @Test + fun `withDefaultSubtype replaces a wildcard subtype with a concrete one`() { + assertThat(MimeTypes.Images.withDefaultSubtype()).isEqualTo(MimeTypes.Jpeg) + assertThat(MimeTypes.Videos.withDefaultSubtype()).isEqualTo(MimeTypes.Mp4) + assertThat(MimeTypes.Audio.withDefaultSubtype()).isEqualTo(MimeTypes.Mp3) + } + + @Test + fun `withDefaultSubtype falls back to octet stream`() { + assertThat(MimeTypes.Any.withDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + assertThat("text/*".withDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + assertThat(null.withDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index d1401d2624..3231c29403 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.media import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.withDefaultSubtype import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource @@ -71,15 +71,8 @@ class RustMediaLoader( val mediaFile = innerClient.getMediaFile( mediaSource = mediaSource, filename = filename, - mimeType = when { - mimeType == null -> MimeTypes.OctetStream - MimeTypes.hasSubtype(mimeType) -> mimeType - // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension. - mimeType == MimeTypes.Images -> MimeTypes.Jpeg - mimeType == MimeTypes.Videos -> MimeTypes.Mp4 - mimeType == MimeTypes.Audio -> MimeTypes.Mp3 - else -> MimeTypes.OctetStream - }, + // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension. + mimeType = mimeType.withDefaultSubtype(), useCache = useCache, tempDir = cacheDirectory.path, ) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index b374e530f4..4ef8a2410c 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.core.mimetype.MimeTypes.withDefaultSubtype import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -82,15 +83,16 @@ class AndroidMediaPreProcessor( mediaOptimizationConfig: MediaOptimizationConfig, ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { + val resolvedMimeType = mimeType.withDefaultSubtype() val result = when { - mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType) - mimeType.isMimeTypeImage() -> { - val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes - processImage(uri, mimeType, shouldBeCompressed) + resolvedMimeType == MimeTypes.Svg -> processSvgImage(uri, resolvedMimeType) + resolvedMimeType.isMimeTypeImage() -> { + val shouldBeCompressed = mediaOptimizationConfig.compressImages && resolvedMimeType !in notCompressibleImageTypes + processImage(uri, resolvedMimeType, shouldBeCompressed) } - mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset) - mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) - else -> processFile(uri, mimeType) + resolvedMimeType.isMimeTypeVideo() -> processVideo(uri, resolvedMimeType, mediaOptimizationConfig.videoCompressionPreset) + resolvedMimeType.isMimeTypeAudio() -> processAudio(uri, resolvedMimeType) + else -> processFile(uri, resolvedMimeType) } if (deleteOriginal) { tryOrNull { diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 3fb639aa9b..b3d5bc805b 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -65,9 +65,24 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { assertThat(height).isEqualTo(2268) } + @Test + fun `test processing an image shared with a wildcard mime type`() = runTest { + val mediaUploadInfo = process( + asset = assetImageJpeg, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg) + } + private suspend fun TestScope.process( asset: Asset, mediaOptimizationConfig: MediaOptimizationConfig, + mimeType: String = asset.mimeType, sdkIntVersion: Int = Build.VERSION_CODES.P, deleteOriginal: Boolean = false, ): MediaUploadInfo { @@ -81,7 +96,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { val file = getFileFromAssets(context, asset.filename) val result = sut.process( uri = file.toUri(), - mimeType = asset.mimeType, + mimeType = mimeType, deleteOriginal = deleteOriginal, mediaOptimizationConfig = mediaOptimizationConfig, ) From ddd89f46df37d8524bff054e1c2835a933054ac6 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:33:09 +0300 Subject: [PATCH 021/118] Resolve the System theme from the application configuration Selecting a Light or Dark theme makes AppCompatDelegate rewrite the activity configuration to the forced night mode. isSystemInDarkTheme reads that same configuration, so switching back to System kept showing the previously forced theme. Read the night mode from the application configuration instead, which AppCompat leaves untouched and itself uses to expand MODE_NIGHT_FOLLOW_SYSTEM. The activity configuration is still used as the recomposition key so a system-wide theme change is picked up. --- .../element/android/compound/theme/Theme.kt | 23 +++++++++++-- .../android/compound/theme/ThemeTest.kt | 34 +++++++++++++++---- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt index bf5932da59..e519984e9f 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt @@ -8,8 +8,11 @@ package io.element.android.compound.theme -import androidx.compose.foundation.isSystemInDarkTheme +import android.content.res.Configuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -27,12 +30,28 @@ private fun Theme.coerceBlackTheme(allowBlackTheme: Boolean): Theme { @Composable fun Theme.isDark(): Boolean { return when (this) { - Theme.System -> isSystemInDarkTheme() + Theme.System -> isSystemThemeDark() Theme.Dark, Theme.Black -> true Theme.Light -> false } } +/** + * Whether the system-wide theme is dark. + * + * This deliberately does not use [androidx.compose.foundation.isSystemInDarkTheme], which reads the activity configuration: `AppCompatDelegate` rewrites + * that configuration whenever a non-system theme is selected, so it keeps reporting the previously forced value. The application configuration is left + * untouched by AppCompat and is what AppCompat itself reads to expand `MODE_NIGHT_FOLLOW_SYSTEM`. + */ +@Composable +private fun isSystemThemeDark(): Boolean { + val applicationContext = LocalContext.current.applicationContext + val activityConfiguration = LocalConfiguration.current + return remember(activityConfiguration) { + applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + } +} + fun Flow.mapToTheme(allowBlackTheme: Boolean): Flow = map { when (it) { null -> Theme.System diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt index 3f43cfea67..57ae9dd332 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt @@ -11,19 +11,22 @@ package io.element.android.compound.theme import android.content.res.Configuration import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test +import org.robolectric.RuntimeEnvironment -class ThemeTest { +class ThemeTest : RobolectricTest() { @Test fun `isDark for System dark returns true`() { `isDark for System`( - uiMode = Configuration.UI_MODE_NIGHT_YES, + systemUiMode = Configuration.UI_MODE_NIGHT_YES, expected = true, ) } @@ -31,21 +34,38 @@ class ThemeTest { @Test fun `isDark for System light return false`() { `isDark for System`( - uiMode = Configuration.UI_MODE_NIGHT_NO, + systemUiMode = Configuration.UI_MODE_NIGHT_NO, expected = false, ) } - fun `isDark for System`( - uiMode: Int, + @Test + fun `isDark for System ignores the night mode AppCompat forced on the activity`() { + `isDark for System`( + systemUiMode = Configuration.UI_MODE_NIGHT_YES, + activityUiMode = Configuration.UI_MODE_NIGHT_NO, + expected = true, + ) + `isDark for System`( + systemUiMode = Configuration.UI_MODE_NIGHT_NO, + activityUiMode = Configuration.UI_MODE_NIGHT_YES, + expected = false, + ) + } + + private fun `isDark for System`( + systemUiMode: Int, + activityUiMode: Int = systemUiMode, expected: Boolean, ) = runTest { + val application = RuntimeEnvironment.getApplication() + application.resources.configuration.uiMode = systemUiMode moleculeFlow(RecompositionMode.Immediate) { var result: Boolean? = null CompositionLocalProvider( - // Let set the system to dark + LocalContext provides application, LocalConfiguration provides Configuration().apply { - this.uiMode = uiMode + this.uiMode = activityUiMode }, ) { result = Theme.System.isDark() From 98ef737b1d42eb2147a31e2f45e2198751c47783 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:52:46 +0300 Subject: [PATCH 022/118] Rename withDefaultSubtype to ensureDefaultSubtype The old name read as though the default subtype always replaced whatever the receiver carried, when a mime type that already has a concrete subtype is returned untouched. --- .../libraries/core/mimetype/MimeTypes.kt | 2 +- .../libraries/core/mimetype/MimeTypesTest.kt | 26 +++++++++---------- .../matrix/impl/media/RustMediaLoader.kt | 4 +-- .../impl/AndroidMediaPreProcessor.kt | 4 +-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index dee840ea48..3901a32059 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -68,7 +68,7 @@ object MimeTypes { * A mime type with a wildcard subtype is a filter, not a media type, so replace it with a * concrete default for its top level type. */ - fun String?.withDefaultSubtype(): String = when { + fun String?.ensureDefaultSubtype(): String = when { this == null -> OctetStream hasSubtype(this) -> this this == Images -> Jpeg diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt index 7ba68fa9b1..f12b73b40b 100644 --- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt @@ -8,28 +8,28 @@ package io.element.android.libraries.core.mimetype import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.core.mimetype.MimeTypes.withDefaultSubtype +import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype import org.junit.Test class MimeTypesTest { @Test - fun `withDefaultSubtype keeps a mime type that already has a subtype`() { - assertThat(MimeTypes.Png.withDefaultSubtype()).isEqualTo(MimeTypes.Png) - assertThat(MimeTypes.Mp4.withDefaultSubtype()).isEqualTo(MimeTypes.Mp4) - assertThat(MimeTypes.Pdf.withDefaultSubtype()).isEqualTo(MimeTypes.Pdf) + fun `ensureDefaultSubtype keeps a mime type that already has a subtype`() { + assertThat(MimeTypes.Png.ensureDefaultSubtype()).isEqualTo(MimeTypes.Png) + assertThat(MimeTypes.Mp4.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4) + assertThat(MimeTypes.Pdf.ensureDefaultSubtype()).isEqualTo(MimeTypes.Pdf) } @Test - fun `withDefaultSubtype replaces a wildcard subtype with a concrete one`() { - assertThat(MimeTypes.Images.withDefaultSubtype()).isEqualTo(MimeTypes.Jpeg) - assertThat(MimeTypes.Videos.withDefaultSubtype()).isEqualTo(MimeTypes.Mp4) - assertThat(MimeTypes.Audio.withDefaultSubtype()).isEqualTo(MimeTypes.Mp3) + fun `ensureDefaultSubtype replaces a wildcard subtype with a concrete one`() { + assertThat(MimeTypes.Images.ensureDefaultSubtype()).isEqualTo(MimeTypes.Jpeg) + assertThat(MimeTypes.Videos.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4) + assertThat(MimeTypes.Audio.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp3) } @Test - fun `withDefaultSubtype falls back to octet stream`() { - assertThat(MimeTypes.Any.withDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) - assertThat("text/*".withDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) - assertThat(null.withDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + fun `ensureDefaultSubtype falls back to octet stream`() { + assertThat(MimeTypes.Any.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + assertThat("text/*".ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + assertThat(null.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 3231c29403..7b3a1d9f10 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.media import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.mimetype.MimeTypes.withDefaultSubtype +import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource @@ -72,7 +72,7 @@ class RustMediaLoader( mediaSource = mediaSource, filename = filename, // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension. - mimeType = mimeType.withDefaultSubtype(), + mimeType = mimeType.ensureDefaultSubtype(), useCache = useCache, tempDir = cacheDirectory.path, ) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 4ef8a2410c..d35fdb6189 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -26,10 +26,10 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo -import io.element.android.libraries.core.mimetype.MimeTypes.withDefaultSubtype import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -83,7 +83,7 @@ class AndroidMediaPreProcessor( mediaOptimizationConfig: MediaOptimizationConfig, ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { - val resolvedMimeType = mimeType.withDefaultSubtype() + val resolvedMimeType = mimeType.ensureDefaultSubtype() val result = when { resolvedMimeType == MimeTypes.Svg -> processSvgImage(uri, resolvedMimeType) resolvedMimeType.isMimeTypeImage() -> { From 11e3cb9b19f59a661d48ef60da58ef0cac2694a1 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:20:43 +0300 Subject: [PATCH 023/118] Keep a focused timeline focused until the event has been rendered Opening a room from a notification asks the timeline to focus the notified event. When that event is outside the loaded live window the controller creates a detached timeline for it, and a detached timeline always carries a forward loading indicator as its last chronological item. The items list is reversed for display, so that indicator is the bottom-most row and is composed on first layout, which unconditionally emits a forward pagination request nobody asked for. Because a notified event sits close to the live end, that one page reaches the end, and the controller responds by closing the detached timeline. The live window replaces the item list while it is still anchored at the bottom, so the user lands on the newest message and the focus request is discarded. Defer automatic forward pagination while a focus request is still pending. Once the focused event has been rendered the viewport has moved off the indicator row, so only a real scroll back to the live end switches to the live timeline, which is what the controller's existing behaviour is for. Backward pagination and the explicit jump-to-live action are untouched. --- .../impl/timeline/TimelinePresenter.kt | 21 ++++- .../impl/timeline/TimelinePresenterTest.kt | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 7cd0d018d2..3f98d2a885 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -166,9 +166,14 @@ class TimelinePresenter( fun handleEvent(event: TimelineEvent) { when (event) { is TimelineEvent.LoadMore -> { - if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) { - // Do not paginate forwards in thread mode, as it's not supported - return + if (event.direction == Timeline.PaginationDirection.FORWARDS) { + if (timelineMode is Timeline.Mode.Thread) { + // Do not paginate forwards in thread mode, as it's not supported + return + } + if (focusRequestState.value.isPending()) { + return + } } localScope.launch { timelineController.paginate(direction = event.direction) @@ -537,6 +542,16 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState { } } +private fun FocusRequestState.isPending(): Boolean { + return when (this) { + is FocusRequestState.Requested, + is FocusRequestState.Loading -> true + is FocusRequestState.Success -> !rendered + FocusRequestState.None, + is FocusRequestState.Failure -> false + } +} + // Workaround for not having the server names available, get possible server names from the user ids of the room members private fun calculateServerNamesForRoom(room: JoinedRoom): List { // If we have no room members, return right ahead diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3b0794305f..3b90196974 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -1059,6 +1059,69 @@ class TimelinePresenterTest { } } + @Test + fun `present - a forward pagination while the focused event is not rendered yet is ignored`() = runTest { + val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> + Result.success(true) + } + val presenter = createTimelinePresenter( + room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last() + assertThat(focusedState.isLive).isFalse() + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + assertThat(expectMostRecentItem().isLive).isFalse() + } + } + + @Test + fun `present - a forward pagination is performed once the focused event has been rendered`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(true) + } + val presenter = createTimelinePresenter( + room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) } + initialState.eventSink.invoke(TimelineEvent.OnFocusEventRender) + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.FORWARDS)) + assertThat(expectMostRecentItem().isLive).isTrue() + } + } + + @Test + fun `present - a backward pagination while the focused event is not rendered yet is performed`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + val presenter = createTimelinePresenter( + room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.BACKWARDS)) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - focus on known event retrieves the event from cache`() = runTest { val timelineItemIndexer = TimelineItemIndexer() @@ -1552,6 +1615,31 @@ class TimelinePresenterTest { return awaitItem() } + private fun aRoomWithDetachedTimeline( + paginateLambda: (Timeline.PaginationDirection) -> Result, + ): FakeJoinedRoom { + val detachedTimeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(eventId = AN_EVENT_ID), + ) + ) + ) + ).apply { + this.paginateLambda = paginateLambda + } + return FakeJoinedRoom( + liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())), + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), + ) + } + private fun roomPermissions( canRedactOther: Boolean = false, canRedactOwn: Boolean = true, From 8e02f0354347baa2af17d8d34cd56486c999dd00 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:35:31 +0300 Subject: [PATCH 024/118] Cover the branches left untested by the uri short circuit An intent carrying uris is now accepted before the mime type list is reached, so the image check that every uri share used to exercise is only covered by an intent which has nothing to send. Add one case per listed mime type without a uri. Cover the rest of the handler while it is under test: an intent with no resolvable type, a send multiple without a stream, an action which is neither send nor send multiple, an empty caption, a plain text intent without text, the mime type reported by the content resolver for the uri, and the explicit intent built for the application which will read it, both when the read permission can be granted and when it cannot. --- .../impl/DefaultShareIntentHandlerTest.kt | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt index 8cde31bd0c..d81bbe053d 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt @@ -7,15 +7,26 @@ package io.element.android.features.share.impl +import android.content.ComponentName +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper import android.content.Intent +import android.content.IntentFilter +import android.database.Cursor import android.net.Uri import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import io.element.android.features.share.api.ShareIntentData import io.element.android.features.share.api.UriToShare +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test +import org.robolectric.Robolectric import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf class DefaultShareIntentHandlerTest : RobolectricTest() { @Test @@ -101,12 +112,192 @@ class DefaultShareIntentHandlerTest : RobolectricTest() { assertThat(result).isNull() } + @Test + fun `an intent without mime type is not handled`() { + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(Intent(Intent.ACTION_SEND)) + assertThat(result).isNull() + } + + @Test + fun `an intent with a listed mime type and no uri is handled`() { + listOf( + MimeTypes.Jpeg, + MimeTypes.Mp4, + MimeTypes.Mp3, + MimeTypes.Pdf, + "file/binary", + "text/html", + MimeTypes.Any, + ).forEach { type -> + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + Intent(Intent.ACTION_SEND).apply { this.type = type } + ) + assertWithMessage("$type was not properly handled").that(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = emptyList(), + ) + ) + } + } + + @Test + fun `a send multiple intent without uri is handled`() { + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = MimeTypes.Jpeg + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = emptyList(), + ) + ) + } + + @Test + fun `an uri is ignored when the intent is neither a send nor a send multiple`() { + val intent = Intent(Intent.ACTION_VIEW).apply { + type = MimeTypes.Jpeg + putExtra(Intent.EXTRA_STREAM, "content://sender/image.jpg".toUri()) + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = emptyList(), + ) + ) + } + + @Test + fun `an empty text is not kept as a caption`() { + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "message/rfc822", uri = uri).apply { + putExtra(Intent.EXTRA_TEXT, "") + } + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + @Test + fun `a plain text intent without text is not handled`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = MimeTypes.PlainText + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isNull() + } + + @Test + fun `a plain text intent with an empty text is not handled`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = MimeTypes.PlainText + putExtra(Intent.EXTRA_TEXT, "") + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isNull() + } + + @Test + fun `the mime type of the uri overrides the one of the intent`() { + Robolectric.setupContentProvider(ZipContentProvider::class.java, "sender") + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = MimeTypes.Any, uri = uri) + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "application/zip")), + ) + ) + } + + @Test + fun `the intent is made explicit for the application which will read the uri`() { + val intent = aSendIntent(type = "message/rfc822", uri = "content://sender/logs.zip".toUri()) + givenAnApplicationHandling(intent) + createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(intent.action).isNull() + assertThat(intent.component).isEqualTo(ComponentName(A_PACKAGE_NAME, AN_ACTIVITY_NAME)) + } + + @Test + fun `the intent stays implicit when the uri permission cannot be granted`() { + val uri = "content://sender/logs.zip".toUri() + val intent = aSendIntent(type = "message/rfc822", uri = uri) + givenAnApplicationHandling(intent) + val result = DefaultShareIntentHandler( + context = FailingGrantUriPermissionContext(RuntimeEnvironment.getApplication()), + ).handleIncomingShareIntent(intent) + assertThat(intent.action).isEqualTo(Intent.ACTION_SEND) + assertThat(intent.component).isNull() + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + private fun aSendIntent(type: String, uri: Uri) = Intent(Intent.ACTION_SEND).apply { this.type = type putExtra(Intent.EXTRA_STREAM, uri) } + private fun givenAnApplicationHandling(intent: Intent) { + val componentName = ComponentName(A_PACKAGE_NAME, AN_ACTIVITY_NAME) + shadowOf(RuntimeEnvironment.getApplication().packageManager).apply { + addActivityIfNotPresent(componentName) + addIntentFilterForActivity( + componentName, + IntentFilter(intent.action).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addDataType(intent.type) + } + ) + } + } + private fun createDefaultShareIntentHandler() = DefaultShareIntentHandler( context = RuntimeEnvironment.getApplication(), ) + + private class FailingGrantUriPermissionContext(context: Context) : ContextWrapper(context) { + override fun grantUriPermission(toPackage: String, uri: Uri, modeFlags: Int) { + error("Unable to grant Uri permission") + } + } + + private class ZipContentProvider : ContentProvider() { + override fun onCreate() = true + + override fun getType(uri: Uri) = "application/zip" + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + } + + private companion object { + const val A_PACKAGE_NAME = "io.element.android.x" + const val AN_ACTIVITY_NAME = "io.element.android.x.MainActivity" + } } From 1f7635f0e37676a8c2d7db1cfb9419d263f76940 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 17 Aug 2026 15:56:59 +0200 Subject: [PATCH 025/118] Fix API break --- .../mediaviewer/impl/local/player/ExoPlayerForPreview.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt index 4da8433cd1..e23cb790db 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -266,4 +266,5 @@ class ExoPlayerForPreview( override fun setVideoCodecParameters(codecParameters: CodecParameters) {} override fun addVideoCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List) {} override fun removeVideoCodecParametersChangeListener(listener: CodecParametersChangeListener) {} + override fun setEnforceAdPlaybackOnTimelineRefresh(enforceAdPlaybackOnTimelineRefresh: Boolean) = throw NotImplementedError() } From bbd6ce134be01c4b093aa182401125ee787adf46 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 17 Aug 2026 10:31:17 -0700 Subject: [PATCH 026/118] Fix account provider dropped on first Continue and address review comments --- .../ConfirmAccountProviderPresenter.kt | 18 +++++-- .../ConfirmAccountProviderView.kt | 23 +++++---- .../ConfirmAccountProviderPresenterTest.kt | 47 +++++++++++++++++++ .../src/main/res/values/temporary.xml | 6 --- 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index fbadcddc15..adb02b0f3d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.changeserver.ChangeServerEvents @@ -28,6 +29,7 @@ import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.preferences.api.store.AppPreferencesStore @AssistedInject @@ -58,12 +60,18 @@ class ConfirmAccountProviderPresenter( var userInput by rememberSaveable { mutableStateOf(null) } val accountProviderInput = userInput ?: accountProvider.url - // Offer the most recent previously-used account provider that the current input is a prefix of. + // Offer an account provider that the current input is a prefix of: the most recent previously-used + // one first, then matrix.org. matrix.org is always available for autocomplete even before any + // sign-in, offered both with and without the https:// scheme so it completes whether the user starts + // typing "matrix.org" or "https://matrix.org". val accountProviderSuggestion = remember(accountProviderInput, homeserverHistory) { val input = accountProviderInput.trim() + val candidates = homeserverHistory + + AuthenticationConfig.MATRIX_ORG_URL + + AuthenticationConfig.MATRIX_ORG_URL.removePrefix("https://") input.takeIf { it.isNotEmpty() } ?.let { prefix -> - homeserverHistory.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } + candidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } } } @@ -94,9 +102,11 @@ class ConfirmAccountProviderPresenter( // Validate (and persist) the chosen account provider before proceeding. This also enforces the // account-provider access control, which the login submit does not run on its own. is ConfirmAccountProviderEvents.Continue -> { - submittedAccountProvider = event.accountProvider + // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works. + val accountProviderUrl = event.accountProvider.trim().ensureProtocol() + submittedAccountProvider = accountProviderUrl changeServerState.eventSink( - ChangeServerEvents.ChangeServer(AccountProvider(url = event.accountProvider.trim())) + ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl)) ) } ConfirmAccountProviderEvents.ClearError -> { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 9cd288bfb1..9a2e2819cc 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -19,6 +19,7 @@ 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.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -88,12 +89,16 @@ fun ConfirmAccountProviderView( ?.substring(input.length) .orEmpty() + // The Continue button lives in a footer slot that is not recomposed while the user types, so a direct + // capture of the field text (via ::submit) goes stale and can submit an empty/prefilled value. Reading + // the value through rememberUpdatedState keeps it current regardless of which composition scope reads it. + val accountProviderToSubmit by rememberUpdatedState(input + suggestionSuffix) + fun submit() { // Dismiss the keyboard and release focus while the account provider is being validated. focusManager.clearFocus(force = true) - // Submit the exact field text (plus any accepted suggestion), read here so it can't lag behind - // fast input such as keyboard autofill. - eventSink(ConfirmAccountProviderEvents.Continue(input + suggestionSuffix)) + // Submit the exact field text (plus any accepted suggestion). + eventSink(ConfirmAccountProviderEvents.Continue(accountProviderToSubmit)) } // Once a validation / login error has been dismissed, return focus and the keyboard to the field so @@ -115,9 +120,9 @@ fun ConfirmAccountProviderView( iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { - CommonStrings.screen_select_server_title_register + CommonStrings.screen_change_server_title_register } else { - CommonStrings.screen_select_server_title_login + CommonStrings.screen_change_server_title_login } ), subTitle = null, @@ -153,13 +158,13 @@ fun ConfirmAccountProviderView( .focusRequester(focusRequester) .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.changeServerServer), - label = stringResource(id = CommonStrings.screen_select_server_textfield_header), - placeholder = stringResource(id = CommonStrings.screen_select_server_textfield_placeholder), + label = stringResource(id = CommonStrings.screen_change_server_textfield_header), + placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder), supportingText = stringResource( id = if (state.isAccountCreation) { - CommonStrings.screen_select_server_textfield_footer_register + CommonStrings.screen_change_server_textfield_footer_register } else { - CommonStrings.screen_select_server_textfield_footer_login + CommonStrings.screen_change_server_textfield_footer_login } ), visualTransformation = ghostTransformation, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 687be70c7c..1bb983ff1a 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -349,6 +349,53 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - offers matrix_org as an autocomplete suggestion even without any history`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://matr")) + val suggestionState = awaitState { it.accountProviderInput == "https://matr" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + } + } + + @Test + fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("matr")) + val suggestionState = awaitState { it.accountProviderInput == "matr" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("matrix.org") + } + } + + @Test + fun `present - continue prepends https to a bare account provider`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + cancelAndIgnoreRemainingEvents() + } + } + /** * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate * account-provider validation states, and returns that state. diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index 2b5788042e..c704e811ff 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -10,10 +10,4 @@ "Collapsed" Active Inactive - "Enter your account provider" - "Choose an account provider" - "Account provider" - "example.com" - "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." - "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." From a6c7042482bd113657ebac6bce3aabec9a751571 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 17 Aug 2026 10:37:27 -0700 Subject: [PATCH 027/118] Configure the account provider homeserver only once when signing in --- .../changeserver/ChangeServerPresenter.kt | 7 +++- .../impl/changeserver/ChangeServerState.kt | 5 ++- .../changeserver/ChangeServerStateProvider.kt | 3 +- .../login/impl/login/LoginModeEvent.kt | 6 +++ .../login/impl/login/LoginModePresenter.kt | 38 ++++++++++--------- .../ConfirmAccountProviderPresenter.kt | 6 ++- .../changeserver/ChangeServerPresenterTest.kt | 4 +- .../ConfirmAccountProviderPresenterTest.kt | 23 +++++++++++ 8 files changed, 66 insertions(+), 26 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index 79f76d5fb5..b7b04beeb2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails @Inject class ChangeServerPresenter( @@ -32,7 +33,7 @@ class ChangeServerPresenter( ) : Presenter { @Composable override fun present(): ChangeServerState { - val changeServerAction: MutableState> = remember { + val changeServerAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } @@ -59,7 +60,7 @@ class ChangeServerPresenter( private suspend fun changeServer( data: AccountProvider, - changeServerAction: MutableState>, + changeServerAction: MutableState>, ) { suspend { defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( @@ -72,6 +73,8 @@ class ChangeServerPresenter( } // Homeserver is valid, remember user choice accountProviderDataSource.setAccountProvider(data) + // Return the resolved details so the caller can sign in without configuring the homeserver again. + details }.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt index 6d8ce25d75..3b4f4d9dad 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -9,10 +9,13 @@ package io.element.android.features.login.impl.changeserver import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog data class ChangeServerState( - val changeServerAction: AsyncData, + // On success, carries the resolved homeserver details so the caller can proceed with login without + // configuring (and re-networking) the homeserver a second time. + val changeServerAction: AsyncData, val localNetworkPermissionDialog: LocalNetworkPermissionDialog, val eventSink: (ChangeServerEvents) -> Unit ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt index 183158bceb..dd1102e25e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog open class ChangeServerStateProvider : PreviewParameterProvider { @@ -45,7 +46,7 @@ open class ChangeServerStateProvider : PreviewParameterProvider = AsyncData.Uninitialized, + changeServerAction: AsyncData = AsyncData.Uninitialized, localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None, ) = ChangeServerState( changeServerAction = changeServerAction, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt index 73400890d5..945b0bc580 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt @@ -7,12 +7,18 @@ package io.element.android.features.login.impl.login +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails + sealed interface LoginModeEvent { data class Submit( val isAccountCreation: Boolean, val homeserverUrl: String, val resolvedHomeserverUrl: String?, val loginHint: String?, + // When the caller has already configured the homeserver (e.g. the account provider screen validated + // it first), these are its resolved details. When set, login reuses them instead of calling + // setHomeserver again, avoiding a redundant network round-trip. + val preConfiguredDetails: MatrixHomeServerDetails? = null, ) : LoginModeEvent data object ClearError : LoginModeEvent diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt index eccfb6b18f..59d156308f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt @@ -75,26 +75,28 @@ class LoginModePresenter( private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState>) { suspend { - authenticationService.setHomeserver(request.homeserverUrl).recoverCatching { - // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one. - if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) { - authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow() - } else { - throw it - } - }.map { matrixHomeServerDetails -> - when { - matrixHomeServerDetails.supportsOAuthLogin -> { - val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login - LoginMode.OAuth( - authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow() - ) + // Reuse the details the caller already resolved when it configured the homeserver, so we don't + // run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves. + val matrixHomeServerDetails = request.preConfiguredDetails + ?: authenticationService.setHomeserver(request.homeserverUrl).recoverCatching { + // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one. + if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) { + authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow() + } else { + throw it } - request.isAccountCreation -> throw AccountCreationNotSupported() - matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin - else -> error("Unsupported authentication flow") + }.getOrThrow() + when { + matrixHomeServerDetails.supportsOAuthLogin -> { + val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login + LoginMode.OAuth( + authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow() + ) } - }.getOrThrow() + request.isAccountCreation -> throw AccountCreationNotSupported() + matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin + else -> error("Unsupported authentication flow") + } }.runCatchingUpdatingState( state = loginMode, errorTransform = { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index adb02b0f3d..e025780bc2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -27,7 +27,6 @@ import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -81,14 +80,17 @@ class ConfirmAccountProviderPresenter( val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider) // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. + // Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again. LaunchedEffect(changeServerState.changeServerAction) { - if (changeServerState.changeServerAction is AsyncData.Success) { + val homeServerDetails = changeServerState.changeServerAction.dataOrNull() + if (homeServerDetails != null) { loginModeState.eventSink( LoginModeEvent.Submit( isAccountCreation = params.isAccountCreation, homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(), resolvedHomeserverUrl = null, loginHint = null, + preConfiguredDetails = homeServerDetails, ) ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index 175b7e3057..38036a8b65 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -73,7 +73,7 @@ class ChangeServerPresenterTest { val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val successState = awaitItem() - assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true))) } } @@ -252,7 +252,7 @@ class ChangeServerPresenterTest { while (finalState.changeServerAction !is AsyncData.Success) { finalState = awaitItem() } - assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true))) } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 1bb983ff1a..e23dd967cb 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -396,6 +396,29 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - continue configures the homeserver only once`() = runTest { + // The consolidated Continue flow validates the account provider and then signs in. Login reuses the + // details resolved during validation, so setHomeserver runs exactly once rather than once per phase. + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + cancelAndIgnoreRemainingEvents() + } + } + /** * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate * account-provider validation states, and returns that state. From 0aa283729c9bef281e883e172115ece686db19ff Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:49:03 +0300 Subject: [PATCH 028/118] Cover every branch of the forward pagination gate Codecov reported 55.55% patch coverage on the previous commit: four of the nine measured lines were only partially covered. No test paginates forwards in a thread timeline, and the focus states Requested, Loading and Failure were never live when a forward pagination arrived, so only the Success branch of isPending() was ever evaluated both ways. Add a test per uncovered state: a forward pagination is deferred while the focus request is still being debounced and while the focused timeline is being created, it is performed again once the request has failed, and it is dropped in a thread timeline while a backward one still goes through. The pagination recorder now also sits on the live timeline, and the presenter is built on that same timeline. Before the detached timeline exists, a pagination that escaped the gate would go to the live one, so without this the new guards would pass against the unfixed presenter. The deferred cases end on the emitted state rather than on cancelAndIgnoreRemainingEvents for the same reason: the recorder throws from inside the presenter coroutine and cancelling swallows it. Verified by reverting the gate: the three deferred-pagination tests then fail with "This lambda should never be called." --- .../impl/timeline/TimelinePresenterTest.kt | 140 +++++++++++++++--- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3b90196974..4847c81f28 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -1064,8 +1064,10 @@ class TimelinePresenterTest { val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> Result.success(true) } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) val presenter = createTimelinePresenter( - room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda), + room = room, + timeline = room.liveTimeline, ) presenter.test { val initialState = awaitFirstItem() @@ -1084,8 +1086,10 @@ class TimelinePresenterTest { val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> Result.success(true) } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) val presenter = createTimelinePresenter( - room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda), + room = room, + timeline = room.liveTimeline, ) presenter.test { val initialState = awaitFirstItem() @@ -1106,8 +1110,10 @@ class TimelinePresenterTest { val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> Result.success(false) } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) val presenter = createTimelinePresenter( - room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda), + room = room, + timeline = room.liveTimeline, ) presenter.test { val initialState = awaitFirstItem() @@ -1122,6 +1128,100 @@ class TimelinePresenterTest { } } + @Test + fun `present - a forward pagination while the focus request is debounced is ignored`() = runTest { + val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> + Result.success(true) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = 1.seconds)) + // The focus request has started and is now waiting for the debounce to elapse + consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Requested } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + assertThat(expectMostRecentItem().isLive).isFalse() + } + } + + @Test + fun `present - a forward pagination while the focused timeline is loading is ignored`() = runTest { + val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> + Result.success(true) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + // The focused timeline is being created, the focus request has not resolved yet + consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Loading } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + assertThat(expectMostRecentItem().isLive).isFalse() + } + } + + @Test + fun `present - a forward pagination after a failed focus request is performed`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + val room = aRoomWithDetachedTimeline( + paginateLambda = paginateLambda, + createTimelineResult = { Result.failure(RuntimeException("An error")) }, + ) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Failure } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.FORWARDS)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - a forward pagination in a thread timeline is ignored`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(true) + } + val timeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply { + this.paginateLambda = paginateLambda + } + val presenter = createTimelinePresenter(timeline = timeline) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + // A backward pagination is still performed in a thread timeline + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.BACKWARDS)) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - focus on known event retrieves the event from cache`() = runTest { val timelineItemIndexer = TimelineItemIndexer() @@ -1617,22 +1717,13 @@ class TimelinePresenterTest { private fun aRoomWithDetachedTimeline( paginateLambda: (Timeline.PaginationDirection) -> Result, + createTimelineResult: () -> Result = { Result.success(aDetachedTimeline(paginateLambda)) }, ): FakeJoinedRoom { - val detachedTimeline = FakeTimeline( - timelineItems = flowOf( - listOf( - MatrixTimelineItem.Event( - uniqueId = A_UNIQUE_ID, - event = anEventTimelineItem(eventId = AN_EVENT_ID), - ) - ) - ) - ).apply { - this.paginateLambda = paginateLambda - } return FakeJoinedRoom( - liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())), - createTimelineResult = { Result.success(detachedTimeline) }, + liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())).apply { + this.paginateLambda = paginateLambda + }, + createTimelineResult = { createTimelineResult() }, baseRoom = FakeBaseRoom( roomPermissions = roomPermissions(), threadRootIdForEventResult = { _ -> Result.success(null) }, @@ -1640,6 +1731,21 @@ class TimelinePresenterTest { ) } + private fun aDetachedTimeline( + paginateLambda: (Timeline.PaginationDirection) -> Result, + ) = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(eventId = AN_EVENT_ID), + ) + ) + ) + ).apply { + this.paginateLambda = paginateLambda + } + private fun roomPermissions( canRedactOther: Boolean = false, canRedactOwn: Boolean = true, From 815b2a895eb9a4a3329e19ed8f5acf57251052c9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Aug 2026 11:18:18 +0200 Subject: [PATCH 029/118] Reorder items and specify type explicitly so that diff is minimal when comparing to a generated file for a variant build. --- plugins/src/main/kotlin/config/BuildTimeConfig.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index 83236f1a5a..ffad44fdc5 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -14,15 +14,14 @@ object BuildTimeConfig { const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c" const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c" const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c" - val METADATA_HOST_REVERSED: String? = null + val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val URL_WEBSITE: String? = null val URL_LOGO: String? = null val URL_COPYRIGHT: String? = null val URL_ACCEPTABLE_USE: String? = null val URL_PRIVACY: String? = null val URL_POLICY: String? = null - val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val SERVICES_MAPTILER_BASE_URL: String? = null val SERVICES_MAPTILER_APIKEY: String? = null val SERVICES_MAPTILER_LIGHT_MAPID: String? = null @@ -33,7 +32,6 @@ object BuildTimeConfig { val SERVICES_SENTRY_DSN_RUST: String? = null val BUG_REPORT_URL: String? = null val BUG_REPORT_APP_NAME: String? = null - - const val PUSH_CONFIG_INCLUDE_FIREBASE = true - const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH = true + const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true + const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true } From ccebb55ca7d9fc8159291899c4792d31d1abf8da Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Aug 2026 15:40:20 +0200 Subject: [PATCH 030/118] Improve documentation --- libraries/pushproviders/firebase/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md index a2a7ad97ed..665118d57e 100644 --- a/libraries/pushproviders/firebase/README.md +++ b/libraries/pushproviders/firebase/README.md @@ -2,6 +2,6 @@ ## Configuration -In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. +In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. -To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `build.gradle.kts` files. +To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `build.gradle.kts` files. From 07983221e69da67885c0fbe394130f22aaaf6744 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Aug 2026 16:25:57 +0200 Subject: [PATCH 031/118] Move google_app_id to resource files. --- .../pushproviders/firebase/build.gradle.kts | 18 ------------------ .../firebase/src/debug/res/values/firebase.xml | 4 ++++ .../src/nightly/res/values/firebase.xml | 4 ++++ .../src/release/res/values/firebase.xml | 4 ++++ .../src/main/kotlin/config/BuildTimeConfig.kt | 3 --- 5 files changed, 12 insertions(+), 21 deletions(-) create mode 100644 libraries/pushproviders/firebase/src/debug/res/values/firebase.xml create mode 100644 libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml create mode 100644 libraries/pushproviders/firebase/src/release/res/values/firebase.xml diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index 1a8e73a4da..33e88dd94c 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -8,7 +8,6 @@ @file:Suppress("UnstableApiUsage") -import config.BuildTimeConfig import extension.setupDependencyInjection import extension.testCommonDependencies import org.gradle.kotlin.dsl.withType @@ -28,27 +27,10 @@ android { buildTypes { getByName("release") { consumerProguardFiles("consumer-proguard-rules.pro") - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE, - ) - } - getByName("debug") { - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG, - ) } register("nightly") { consumerProguardFiles("consumer-proguard-rules.pro") matchingFallbacks += listOf("release") - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY, - ) } } } diff --git a/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml new file mode 100644 index 0000000000..e96f163603 --- /dev/null +++ b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:def0a4e454042e9b00427c + diff --git a/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml new file mode 100644 index 0000000000..7275ff2ddd --- /dev/null +++ b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:e17435e0beb0303000427c + diff --git a/libraries/pushproviders/firebase/src/release/res/values/firebase.xml b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml new file mode 100644 index 0000000000..3a9a4330d1 --- /dev/null +++ b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:d097de99a4c23d2700427c + diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index ffad44fdc5..e31ffea658 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -11,9 +11,6 @@ package config object BuildTimeConfig { const val APPLICATION_ID = "io.element.android.x" const val APPLICATION_NAME = "Element X" - const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c" - const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c" - const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c" val METADATA_HOST_REVERSED: String? = null val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val URL_WEBSITE: String? = null From 1fefbc994356b04b4fdee511906789e8f3ecbd4a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Aug 2026 17:39:21 +0200 Subject: [PATCH 032/118] Firebase configuration location has changed. --- libraries/pushproviders/firebase/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md index 665118d57e..d9732af0d0 100644 --- a/libraries/pushproviders/firebase/README.md +++ b/libraries/pushproviders/firebase/README.md @@ -4,4 +4,4 @@ In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. -To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `build.gradle.kts` files. +To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `firebase.xml` files. From c31d7e6cce3a9c39d3eab610fdb97ac6a750138d Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 18 Aug 2026 18:50:57 +0200 Subject: [PATCH 033/118] Don't restart the sync service if the client is shutting down (#7472) * Don't restart the sync service if the client is shutting down This was causing issues when clearing the cache: 1. The client would ask the SDK to clear the caches. 2. The SDK would stop the sync service, clear the caches and set the `pos` value of sliding sync to 0 so the HS returns all the required state the next time the client is used. 3. However, between the sync service being stopped with the `pos=0` change and the client being destroyed, there was enough time for the `SyncOrchestrator` to decide the sync service was stopped for no good reason since there is network connectivity and the app is on foreground, so it restarted the sync, with `pos=0`. 4. This value was persisted, and the next time the new client did an 'initial sync' it had a `pos != 0`, which the HS uses to avoid returning some required state it already fetched. 5. The room list now lacks state events for lots of rooms and room names, avatars, members, etc. are now missing. --------- Co-authored-by: Benoit Marty --- .../android/appnav/di/MatrixSessionCache.kt | 2 +- .../android/appnav/di/SyncOrchestrator.kt | 15 +++-- .../android/appnav/SyncOrchestratorTest.kt | 57 +++++++++++++++---- .../appnav/di/MatrixSessionCacheTest.kt | 6 +- .../libraries/matrix/api/MatrixClient.kt | 3 + .../libraries/matrix/impl/RustMatrixClient.kt | 9 +++ .../libraries/matrix/test/FakeMatrixClient.kt | 4 ++ 7 files changed, 76 insertions(+), 20 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt index 11337f2e0a..a4bccfd6d7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -115,7 +115,7 @@ class MatrixSessionCache( private fun onNewMatrixClient(matrixClient: MatrixClient) { val syncOrchestrator = syncOrchestratorFactory.create( - syncService = matrixClient.syncService, + matrixClient = matrixClient, sessionCoroutineScope = matrixClient.sessionCoroutineScope, ) sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt index 1ce423a569..35a6a224ae 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt @@ -16,7 +16,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope -import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.recordTransaction @@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds @AssistedInject class SyncOrchestrator( - @Assisted private val syncService: SyncService, + @Assisted private val matrixClient: MatrixClient, @Assisted sessionCoroutineScope: CoroutineScope, private val appForegroundStateService: AppForegroundStateService, private val networkMonitor: NetworkMonitor, @@ -47,13 +47,15 @@ class SyncOrchestrator( @AssistedFactory interface Factory { fun create( - syncService: SyncService, + matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope, ): SyncOrchestrator } private val tag = "SyncOrchestrator" + private val syncService = matrixClient.syncService + private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag) private val started = AtomicBoolean(false) @@ -94,7 +96,7 @@ class SyncOrchestrator( appForegroundStateService.isInCall, appForegroundStateService.isSyncingNotificationEvent, appForegroundStateService.hasRingingCall, - appForegroundStateService.isSharingLiveLocation + appForegroundStateService.isSharingLiveLocation, ) val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } } combine( @@ -103,6 +105,11 @@ class SyncOrchestrator( networkMonitor.connectivity, isAppActiveFlow, ) { syncState, networkState, isAppActive -> + if (matrixClient.isShuttingDown) { + Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again") + return@combine SyncStateAction.NoOp + } + val isNetworkAvailable = networkState == NetworkStatus.Connected Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable") diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 7309ca6ac2..ab19ae0b54 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -12,6 +12,7 @@ import io.element.android.appnav.di.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService @@ -40,7 +41,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -60,7 +61,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -85,7 +86,7 @@ class SyncOrchestratorTest { val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -117,7 +118,7 @@ class SyncOrchestratorTest { val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -165,7 +166,7 @@ class SyncOrchestratorTest { initialIsSyncingNotificationEventValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -208,7 +209,7 @@ class SyncOrchestratorTest { initialIsSyncingNotificationEventValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -252,7 +253,7 @@ class SyncOrchestratorTest { initialHasRingingCall = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -296,7 +297,7 @@ class SyncOrchestratorTest { initialIsInCallValue = true, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -339,7 +340,7 @@ class SyncOrchestratorTest { initialIsInCallValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -369,7 +370,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -381,12 +382,44 @@ class SyncOrchestratorTest { startSyncRecorder.assertions().isNeverCalled() } + @Test + fun `when the client is shutting down, the orchestrator does nothing`() = runTest { + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + initialIsSyncingNotificationEventValue = false, + initialIsInCallValue = false, + ) + + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }), + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // These should still not trigger a sync, sync the client is shutting down + networkMonitor.givenNetworkBlocked(false) + appForegroundStateService.updateIsInCallState(true) + appForegroundStateService.isInForeground.value = true + + advanceTimeBy(10.seconds) + + startSyncRecorder.assertions().isNeverCalled() + } + private fun TestScope.createSyncOrchestrator( - syncService: FakeSyncService = FakeSyncService(), + matrixClient: FakeMatrixClient = FakeMatrixClient(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), ) = SyncOrchestrator( - syncService = syncService, + matrixClient = matrixClient, sessionCoroutineScope = backgroundScope, networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt index 36fa471dd0..cba8d06f90 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt @@ -11,7 +11,7 @@ package io.element.android.appnav.di import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat import io.element.android.features.networkmonitor.test.FakeNetworkMonitor -import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService @@ -138,11 +138,11 @@ class MatrixSessionCacheTest { return object : SyncOrchestrator.Factory { override fun create( - syncService: SyncService, + matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope, ): SyncOrchestrator { return SyncOrchestrator( - syncService = syncService, + matrixClient = matrixClient, sessionCoroutineScope = sessionCoroutineScope, appForegroundStateService = FakeAppForegroundStateService(), networkMonitor = FakeNetworkMonitor(), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index b83d96af96..92859060dc 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -136,6 +136,9 @@ interface MatrixClient : UrlContentFetcher { /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */ + val isShuttingDown: Boolean + /** * Returns the room with the given id if the user has joined it, along with its live [Timeline]. * A new instance is created on each call and the caller owns it, so it must be closed once no longer needed. 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 0ff563a204..d7fc7b98e2 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 @@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File import java.util.Optional +import java.util.concurrent.atomic.AtomicBoolean import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -304,6 +305,10 @@ class RustMatrixClient( .buffer(Channel.UNLIMITED) .stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf()) + private val _isShuttingDown = AtomicBoolean(false) + override val isShuttingDown: Boolean + get() = _isShuttingDown.get() + init { // Make sure the session delegate has a reference to the client to be able to logout on auth error sessionDelegate.bindClient(this) @@ -666,11 +671,13 @@ class RustMatrixClient( } override suspend fun clearCache() { + _isShuttingDown.set(true) innerClient.clearCaches(innerSyncService) destroy() } override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) { + _isShuttingDown.set(true) sessionCoroutineScope.cancel() // Remove current delegate so we don't receive an auth error clientDelegateTaskHandle?.cancelAndDestroy() @@ -708,6 +715,8 @@ class RustMatrixClient( } override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = withContext(sessionDispatcher) { + _isShuttingDown.set(true) + Timber.w("Deactivating account") // Remove current delegate so we don't receive an auth error clientDelegateTaskHandle?.cancelAndDestroy() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 56d25ed175..408a3a0f3b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -133,6 +133,7 @@ class FakeMatrixClient( private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, override val contentScanner: ContentScanner? = null, + private val isShuttingDownResult: () -> Boolean = { false }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -305,6 +306,9 @@ class FakeMatrixClient( return knockRoomLambda(roomIdOrAlias, message, serverNames) } + override val isShuttingDown: Boolean + get() = isShuttingDownResult() + // Mocks fun givenCreateRoomResult(result: Result) { From 1b2d5b9c92e2ab6efbbfbaff8989f9ccce2c4303 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 18 Aug 2026 17:03:39 -0700 Subject: [PATCH 034/118] Complete account providers without the https scheme and include the enterprise allow-list --- .../ConfirmAccountProviderPresenter.kt | 44 ++++++++---- .../ConfirmAccountProviderView.kt | 22 ++++-- .../ConfirmAccountProviderPresenterTest.kt | 67 ++++++++++++++++--- 3 files changed, 105 insertions(+), 28 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index e025780bc2..21bc9077dd 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -21,6 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.changeserver.ChangeServerEvents @@ -38,6 +39,7 @@ class ConfirmAccountProviderPresenter( private val loginModePresenter: Presenter, private val changeServerPresenter: Presenter, private val appPreferencesStore: AppPreferencesStore, + private val enterpriseService: EnterpriseService, ) : Presenter { data class Params( val isAccountCreation: Boolean, @@ -55,22 +57,28 @@ class ConfirmAccountProviderPresenter( val changeServerState = changeServerPresenter.present() val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) - // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. - var userInput by rememberSaveable { mutableStateOf(null) } - val accountProviderInput = userInput ?: accountProvider.url + // The account providers offered for autocomplete: previously-used ones first, then the + // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in). + // The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is + // rendered without the https:// scheme (added back at connection time). + val autocompleteCandidates = remember(homeserverHistory) { + (homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL) + .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } + .map { it.withoutScheme() } + .distinct() + } - // Offer an account provider that the current input is a prefix of: the most recent previously-used - // one first, then matrix.org. matrix.org is always available for autocomplete even before any - // sign-in, offered both with and without the https:// scheme so it completes whether the user starts - // typing "matrix.org" or "https://matrix.org". - val accountProviderSuggestion = remember(accountProviderInput, homeserverHistory) { + // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. + // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org". + var userInput by rememberSaveable { mutableStateOf(null) } + val accountProviderInput = userInput ?: accountProvider.url.withoutScheme() + + // Offer the first candidate that the current input is a (case-insensitive) prefix of. + val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) { val input = accountProviderInput.trim() - val candidates = homeserverHistory + - AuthenticationConfig.MATRIX_ORG_URL + - AuthenticationConfig.MATRIX_ORG_URL.removePrefix("https://") input.takeIf { it.isNotEmpty() } ?.let { prefix -> - candidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } + autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } } } @@ -104,8 +112,12 @@ class ConfirmAccountProviderPresenter( // Validate (and persist) the chosen account provider before proceeding. This also enforces the // account-provider access control, which the login submit does not run on its own. is ConfirmAccountProviderEvents.Continue -> { + // Apply the accepted account provider (any accepted completion) back into the field so it + // renders the full server rather than the typed prefix, and survives the OAuth round-trip. + val accountProvider = event.accountProvider.trim() + userInput = accountProvider // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works. - val accountProviderUrl = event.accountProvider.trim().ensureProtocol() + val accountProviderUrl = accountProvider.ensureProtocol() submittedAccountProvider = accountProviderUrl changeServerState.eventSink( ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl)) @@ -128,3 +140,9 @@ class ConfirmAccountProviderPresenter( ) } } + +/** + * The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed. + * Returns the input unchanged when it has no scheme (e.g. `matrix.org`). + */ +private fun String.withoutScheme(): String = substringAfter("://") diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 9a2e2819cc..c81fd529ef 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -50,7 +50,6 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.form.textFieldState -import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext 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 @@ -82,8 +81,9 @@ fun ConfirmAccountProviderView( val keyboardController = LocalSoftwareKeyboardController.current var input by textFieldState(stateValue = state.accountProviderInput) + // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and - // accepted on Continue). Only valid while the suggestion still starts with the current input. + // accepted on Continue / Enter). Only valid while the suggestion still starts with the current input. val suggestionSuffix = state.accountProviderSuggestion ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } ?.substring(input.length) @@ -156,7 +156,6 @@ fun ConfirmAccountProviderView( .fillMaxWidth() .padding(top = 40.dp) .focusRequester(focusRequester) - .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.changeServerServer), label = stringResource(id = CommonStrings.screen_change_server_textfield_header), placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder), @@ -172,7 +171,22 @@ fun ConfirmAccountProviderView( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done, ), - keyboardActions = KeyboardActions(onDone = { submit() }), + keyboardActions = KeyboardActions( + onDone = { + if (suggestionSuffix.isNotEmpty()) { + // A completion is showing: accept it into the field and drop focus, dismissing the + // keyboard. This shows the completed server (with no stray mid-text cursor, since the + // field is no longer focused) ready to sign in with Continue. When no completion is + // showing, Done submits directly. + val accepted = input + suggestionSuffix + input = accepted + eventSink(ConfirmAccountProviderEvents.UserInputChanged(accepted)) + focusManager.clearFocus(force = true) + } else { + submit() + } + } + ), singleLine = true, trailingIcon = if (input.isNotEmpty()) { { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 826c5cbfdf..229fc375a2 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource @@ -50,7 +51,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.submitEnabled).isTrue() - assertThat(initialState.accountProviderInput).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.accountProviderInput).isEqualTo("matrix.org") assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -315,9 +316,10 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() assertThat(initialState.accountProviderSuggestion).isNull() - initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) - val suggestionState = awaitState { it.accountProviderInput == "https://random" } - assertThat(suggestionState.accountProviderSuggestion).isEqualTo("https://randomcommunity.org") + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderInput == "random" } + // Completion is offered without the https:// scheme, even though history stores the full URL. + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org") } } @@ -338,9 +340,9 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) val suggestionState = awaitState { it.accountProviderSuggestion != null } - suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("https://randomcommunity.org")) + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org")) awaitLoginMode { it is AsyncData.Success } assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") cancelAndIgnoreRemainingEvents() @@ -348,15 +350,40 @@ class ConfirmAccountProviderPresenterTest { } @Test - fun `present - offers matrix_org as an autocomplete suggestion even without any history`() = runTest { + fun `present - continue applies the completed account provider back into the field`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, + ) val presenter = createConfirmAccountProviderPresenter( - appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + matrixAuthenticationService = authenticationService, + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")), ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://matr")) - val suggestionState = awaitState { it.accountProviderInput == "https://matr" } - assertThat(suggestionState.accountProviderSuggestion).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderSuggestion != null } + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org")) + // The field now renders the full accepted server rather than the typed prefix. + val appliedState = awaitState { it.accountProviderInput == "randomcommunity.org" } + assertThat(appliedState.accountProviderInput).isEqualTo("randomcommunity.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - offers the enterprise allow-list servers as autocomplete suggestions`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + enterpriseService = FakeEnterpriseService( + // Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete. + defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("ele")) + val suggestionState = awaitState { it.accountProviderInput == "ele" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("element.io") } } @@ -373,6 +400,22 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - does not autocomplete a scheme-prefixed input`() = runTest { + // Candidates are bare hosts, so a scheme-prefixed input never completes them - the user completes by + // typing the bare host. This also avoids ever suggesting an insecure http scheme. + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://mat")) + assertThat(awaitState { it.accountProviderInput == "https://mat" }.accountProviderSuggestion).isNull() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("http://mat")) + assertThat(awaitState { it.accountProviderInput == "http://mat" }.accountProviderSuggestion).isNull() + } + } + @Test fun `present - continue prepends https to a bare account provider`() = runTest { val submittedUrls = mutableListOf() @@ -440,10 +483,12 @@ class ConfirmAccountProviderPresenterTest { matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), ) = ConfirmAccountProviderPresenter( params = params, accountProviderDataSource = accountProviderDataSource, appPreferencesStore = appPreferencesStore, + enterpriseService = enterpriseService, loginModePresenter = createLoginModePresenter( authenticationService = matrixAuthenticationService, oAuthActionFlow = defaultOAuthActionFlow, From 2e2bf71ad53cfd2fa5bce7c53a18a0dfd6a93a31 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 18 Aug 2026 17:03:40 -0700 Subject: [PATCH 035/118] Make ensureProtocol case-insensitive --- .../android/libraries/core/uri/UrlUtils.kt | 5 +-- .../libraries/core/uri/UrlUtilsTest.kt | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt index 666ee1d7ab..91402e3d11 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt @@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean { } /** - * Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty + * Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is + * not the case, "https://" is added, only if the String is not empty. */ fun String.ensureProtocol(): String { return when { isEmpty() -> this - !startsWith("http") -> "https://$this" + !startsWith("http", ignoreCase = true) -> "https://$this" else -> this } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt new file mode 100644 index 0000000000..9a9d68dcaa --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.uri + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UrlUtilsTest { + @Test + fun `ensureProtocol keeps an empty string unchanged`() { + assertThat("".ensureProtocol()).isEqualTo("") + } + + @Test + fun `ensureProtocol prepends https when there is no scheme`() { + assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org") + } + + @Test + fun `ensureProtocol keeps an existing http or https scheme`() { + assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org") + assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org") + } + + @Test + fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() { + assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org") + assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org") + } +} From a1e86ad499d03fc899b86236c3b114a21da4b4e8 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 19 Aug 2026 09:24:22 +0200 Subject: [PATCH 036/118] Use the right base URL for the ESS config/well known endpoints (#7469) * Rename `UrlContentFetcher` to `ClientUrlContentFetcher` and add missing base URL data (server name, homeserver URL) * Avoid passing the `homeserverUrl/serverName` around. Calculate the URL in `DefaultRemoteEnterpriseConfigRetriever` using the newly added `homserver` and `server` values as the possible base URLs, depending on the remote config source * Make sure we don't perform blocking requests to fetch the remote config when restoring a session Add a `needsRefresh` parameter to `RemoteEnterpriseConfigResult.NotFound` to handle this, and store a `no-config` value in the store to indicate an endpoint has been checked and no config was found * Bump enterprise module --- enterprise | 2 +- .../enterprise/api/EnterpriseService.kt | 17 ++++----------- .../RemoteEnterpriseConfigResult.kt | 10 +++++---- .../impl/DefaultEnterpriseService.kt | 8 +++---- .../DefaultContentScannerUrlProvider.kt | 4 ++-- .../enterprise/test/FakeEnterpriseService.kt | 17 ++++++--------- ...tFetcher.kt => ClientUrlContentFetcher.kt} | 14 +++++++++++-- .../libraries/matrix/api/MatrixClient.kt | 5 +---- .../matrix/api/TemporaryMatrixClient.kt | 2 +- .../api/TemporaryMatrixClientFactory.kt | 6 +++--- .../api/scanner/ContentScannerUrlProvider.kt | 4 ++-- .../libraries/matrix/impl/RustMatrixClient.kt | 1 + .../matrix/impl/RustTemporaryMatrixClient.kt | 3 +++ .../impl/RustTemporaryMatrixClientFactory.kt | 4 ++-- .../auth/RustMatrixAuthenticationService.kt | 1 - .../matrix/impl/di/SessionMatrixModule.kt | 4 ++-- .../impl/RustTemporaryMatrixClientTest.kt | 3 +-- .../impl/fixtures/fakes/FakeFfiClient.kt | 3 +++ .../test/FakeClientUrlContentFetcher.kt | 21 +++++++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 1 + .../matrix/test/FakeTemporaryMatrixClient.kt | 3 ++- .../test/FakeTemporaryMatrixClientFactory.kt | 2 +- .../android/libraries/matrix/test/TestData.kt | 1 + 23 files changed, 79 insertions(+), 57 deletions(-) rename libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/{UrlContentFetcher.kt => ClientUrlContentFetcher.kt} (63%) create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt diff --git a/enterprise b/enterprise index 3bf0df1f25..c87b257dd8 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 3bf0df1f25c4df8ee6cc226b00d9b6a675b37a09 +Subproject commit c87b257dd87fedc9ca6a4001d0318aae5a453284 diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 2f1cbe1187..2cb4a356be 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -10,7 +10,7 @@ package io.element.android.features.enterprise.api import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow @@ -31,10 +31,9 @@ interface EnterpriseService { * Rewrites the authentication server URL when the deployment requires a different one from the one advertised. * * @param url the URL to rewrite. - * @param homeserver the homeserver the URL belongs to. * @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works. */ - suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String + suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String /** * Returns the list of homeservers the user is allowed to sign in to. @@ -53,9 +52,9 @@ interface EnterpriseService { /** * Whether the given homeserver enforces the use of Element Pro or a derived app. * - * @param homeserverUrl the homeserver to check. + * @param serverName the homeserver to check. */ - suspend fun isElementProEnforced(homeserverUrl: String): Boolean + suspend fun isElementProEnforced(serverName: String): Boolean /** * Override the brand color. @@ -99,14 +98,6 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? - /** - * Gets the Element Server Suite (ESS) config endpoint URL for the given domain. - * Returns `null` when this build does not read its configuration from an ESS deployment. - * - * @param domain the server whose configuration endpoint is requested. - */ - fun essConfigEndpointUrl(domain: String): String? - companion object { const val ANY_ACCOUNT_PROVIDER = "*" } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt index 5f687ff052..ace0bada0b 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt @@ -19,9 +19,11 @@ sealed interface RemoteEnterpriseConfigResult { data class Outdated(val data: T) : RemoteEnterpriseConfigResult /** - * Well-known data is not found (file does not exist server side, we got a 404). + * Well-known data is not found (file does not exist server side, we got a 404). `needsRefresh` indicates whether a refresh must be done + * to replace this value: if it's `true`, the caller must trigger a refresh to try to fetch the data again; + * if it's `false`, the caller can assume that the data is not available and no refresh is needed before returning the data. */ - data object NotFound : RemoteEnterpriseConfigResult + data class NotFound(val needsRefresh: Boolean) : RemoteEnterpriseConfigResult /** * Any other error. @@ -32,13 +34,13 @@ sealed interface RemoteEnterpriseConfigResult { is Success -> data is Outdated -> data is Error -> null - NotFound -> null + is NotFound -> null } fun upToDateDataOrNull(): T? = when (this) { is Success -> data is Outdated -> null is Error -> null - NotFound -> null + is NotFound -> null } } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index d07c0db569..bb10550369 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -14,7 +14,7 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -22,10 +22,10 @@ import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) class DefaultEnterpriseService : EnterpriseService { override suspend fun isEnterpriseUser(sessionId: SessionId) = false - override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url + override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override fun homeserverAllowList(): List = emptyList() override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true - override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = false + override suspend fun isElementProEnforced(serverName: String): Boolean = false override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit @@ -45,6 +45,4 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null - - override fun essConfigEndpointUrl(domain: String): String? = null } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt index 4d4a800de9..378e4570d9 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt @@ -9,7 +9,7 @@ package io.element.android.features.enterprise.impl.scanner import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider @@ -22,7 +22,7 @@ class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { @ContributesBinding(AppScope::class) class Factory : ContentScannerUrlProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider { + override fun create(urlContentFetcher: ClientUrlContentFetcher): ContentScannerUrlProvider { return DefaultContentScannerUrlProvider() } } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index e346e5934a..a8efcb5873 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -30,8 +30,7 @@ class FakeEnterpriseService( private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, - private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() }, - private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() }, + private val tweakMasUrlResult: (String, ClientUrlContentFetcher) -> String = { _, _ -> lambdaError() }, private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() }, ) : EnterpriseService { private val brandColorState = MutableStateFlow(initialBrandColor) @@ -41,8 +40,8 @@ class FakeEnterpriseService( isEnterpriseUserResult(sessionId) } - override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask { - tweakMasUrlResult(url, homeserver, urlContentFetcher) + override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String = simulateLongTask { + tweakMasUrlResult(url, urlContentFetcher) } override fun homeserverAllowList(): List { @@ -53,8 +52,8 @@ class FakeEnterpriseService( isAllowedToConnectToHomeserverResult(homeserverUrl) } - override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = simulateLongTask { - isElementProEnforcedResult(homeserverUrl) + override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask { + isElementProEnforcedResult(serverName) } override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask { @@ -85,8 +84,4 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } - - override fun essConfigEndpointUrl(domain: String): String { - return essConfigEndpointUrlResult(domain) - } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt similarity index 63% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt index a6fb234b86..611bf7bf18 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt @@ -8,9 +8,19 @@ package io.element.android.libraries.matrix.api /** - * An interface to fetch content from a URL using a simple GET request. + * An interface to fetch content from a URL using a simple GET request launched from a Matrix client. */ -fun interface UrlContentFetcher { +interface ClientUrlContentFetcher { + /** + * The domain of the public server for this homeserver. + */ + val server: String? + + /** + * The URL of the actual Matrix homeserver. + */ + val homeserverUrl: String + /** * Execute generic GET requests through the SDKs internal HTTP client. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 92859060dc..02ed16e097 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -60,16 +60,13 @@ import java.util.Optional * One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation. * Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread. */ -interface MatrixClient : UrlContentFetcher { +interface MatrixClient : ClientUrlContentFetcher { /** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */ val sessionId: SessionId /** Identifier of this device, as assigned by the homeserver when the session was created. */ val deviceId: DeviceId - /** Base URL of the homeserver this client is connected to. */ - val homeserverUrl: String - /** Directories holding this session's databases, caches and downloaded media. */ val sessionPaths: SessionPaths diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt index 1e3ffea154..5013362f85 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt @@ -15,4 +15,4 @@ import java.lang.AutoCloseable * Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated * with it, such as temporary files. */ -interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable +interface TemporaryMatrixClient : ClientUrlContentFetcher, AutoCloseable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt index 3bd22c64f3..c3244b4742 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt @@ -12,10 +12,10 @@ package io.element.android.libraries.matrix.api */ interface TemporaryMatrixClientFactory { /** - * Builds an unauthenticated client pointing at [homeServerUrl], backed by a fresh set of temporary session directories. + * Builds an unauthenticated client pointing at [serverName], backed by a fresh set of temporary session directories. * The caller owns the returned client and must close it to delete those directories. * - * @param homeServerUrl the URL of the homeserver the client will talk to. + * @param serverName the domain of the public-facing server for the actual homeserver */ - suspend fun create(homeServerUrl: String): Result + suspend fun create(serverName: String): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt index 2c11c750e4..0ed3f8f2a2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt @@ -7,7 +7,7 @@ package io.element.android.libraries.matrix.api.scanner -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId /** @@ -26,6 +26,6 @@ fun interface ContentScannerUrlProvider { /** * @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works. */ - fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider + fun create(urlContentFetcher: ClientUrlContentFetcher): ContentScannerUrlProvider } } 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 d7fc7b98e2..16371fcbf8 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 @@ -163,6 +163,7 @@ class RustMatrixClient( ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) + override val server: String? = innerClient.server() override val homeserverUrl: String = innerClient.homeserver() override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId") private val sessionDispatcher = dispatchers.io.limitedParallelism(64) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt index 54e4f696d3..f5b1f2ba4e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt @@ -22,6 +22,9 @@ class RustTemporaryMatrixClient( client.getUrl(url) }.mapFailure { it.mapClientException() } + override val server: String? = client.server() + override val homeserverUrl: String = client.homeserver() + override fun close() { client.close() paths?.let { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt index 70903ba91a..0e7d8bf2c5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt @@ -19,7 +19,7 @@ class RustTemporaryMatrixClientFactory( private val sessionPathsFactory: SessionPathsFactory, private val rustMatrixClientFactory: RustMatrixClientFactory, ) : TemporaryMatrixClientFactory { - override suspend fun create(homeServerUrl: String): Result { + override suspend fun create(serverName: String): Result { return runCatchingExceptions { val sessionPaths = sessionPathsFactory.create() val client = rustMatrixClientFactory.getBaseClientBuilder( @@ -28,7 +28,7 @@ class RustTemporaryMatrixClientFactory( slidingSyncType = ClientBuilderSlidingSync.Native, isMessageSearchAvailable = false, ) - .homeserverUrl(homeServerUrl) + .serverName(serverName) .build() RustTemporaryMatrixClient(client, sessionPaths) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 2c936c9b23..153aa6d1d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -278,7 +278,6 @@ class RustMatrixAuthenticationService( .let { enterpriseService.tweakMasUrl( url = it, - homeserver = client.server() ?: client.homeserver(), urlContentFetcher = getUrlResolver, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 46fa885d25..569714181b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -13,9 +13,9 @@ import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.UrlContentFetcher import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -111,7 +111,7 @@ object SessionMatrixModule { } @Provides - fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher { + fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher { return matrixClient } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt index 322103042b..23e0cf2302 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt @@ -16,7 +16,6 @@ import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.NoHandle import java.io.File import kotlin.io.path.createTempDirectory import org.matrix.rustcomponents.sdk.ClientException as RustClientException @@ -84,7 +83,7 @@ class RustTemporaryMatrixClientTest { } private fun createRustTemporaryMatrixClient( - client: Client = Client(NoHandle), + client: Client = FakeFfiClient(), paths: SessionPaths, ): RustTemporaryMatrixClient { return RustTemporaryMatrixClient(client, paths) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 13be3cf2d5..f09a350971 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_SERVER_NAME import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -45,6 +46,7 @@ class FakeFfiClient( private val userId: String = A_USER_ID.value, private val deviceId: String = A_DEVICE_ID.value, private val homeserver: String = A_HOMESERVER_URL, + private val server: String? = A_SERVER_NAME, private val notificationClient: NotificationClient = FakeFfiNotificationClient(), private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(), private val encryption: Encryption = FakeFfiEncryption(), @@ -66,6 +68,7 @@ class FakeFfiClient( override fun userId(): String = userId override fun deviceId(): String = deviceId override fun homeserver(): String = homeserver + override fun server(): String? = server override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings override fun encryption(): Encryption = encryption diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt new file mode 100644 index 0000000000..2018228985 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeClientUrlContentFetcher( + override val server: String = A_SERVER_NAME, + override val homeserverUrl: String = A_HOMESERVER_URL, + private val getUrlResult: (String) -> Result = { lambdaError() }, +) : ClientUrlContentFetcher { + override suspend fun getUrl(url: String): Result { + return getUrlResult(url) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 408a3a0f3b..6f330b6536 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -79,6 +79,7 @@ class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")), override val deviceId: DeviceId = A_DEVICE_ID, + override val server: String = A_SERVER_NAME, override val homeserverUrl: String = A_HOMESERVER_URL, override val sessionCoroutineScope: CoroutineScope = TestScope(), private val userDisplayName: String? = A_USER_NAME, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt index 83bde4653a..3ee7fb5f2f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt @@ -11,10 +11,11 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClient import io.element.android.tests.testutils.lambda.lambdaError class FakeTemporaryMatrixClient( + override val server: String = A_SERVER_NAME, + override val homeserverUrl: String = A_HOMESERVER_URL, private val getUrlResult: (String) -> Result = { lambdaError() }, private val closeLambda: () -> Unit = {}, ) : TemporaryMatrixClient { override suspend fun getUrl(url: String): Result = getUrlResult(url) - override fun close() = closeLambda() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt index cdec68bdd1..e906777dcb 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt @@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory class FakeTemporaryMatrixClientFactory( private val createResult: (String) -> Result = { Result.success(FakeTemporaryMatrixClient()) }, ) : TemporaryMatrixClientFactory { - override suspend fun create(homeServerUrl: String): Result = createResult(homeServerUrl) + override suspend fun create(serverName: String): Result = createResult(serverName) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index c41ce0fe3e..c8f49eaf56 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -72,6 +72,7 @@ const val A_SPACE_NAME = "A space name" const val A_REDACTION_REASON = "A redaction reason" +const val A_SERVER_NAME = "matrix.org" const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL_2 = "matrix-client.org" From ff89302cf5e39fbe3cc8213ab005f4d5a9b49a0a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 19 Aug 2026 11:56:34 +0200 Subject: [PATCH 037/118] Normalize the applicationName used in user agent, as HTTP spec does not allow UTF-8 chars in headers. --- .../libraries/core/extensions/BasicExtensions.kt | 12 ++++++++++++ .../libraries/core/extensions/BasicExtensionsTest.kt | 6 ++++++ .../network/useragent/DefaultUserAgentProvider.kt | 5 +++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index fe563138ad..87d6350c65 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.core.extensions +import java.text.Normalizer import java.util.Locale fun Boolean.toOnOff() = if (this) "ON" else "OFF" @@ -114,3 +115,14 @@ fun String.toSafeLength( this } } + +/** + * Remove accents and replace special characters by ASCII from the string. + * For instance, "é" will be replaced by "e", "ç" by "c", etc. + */ +fun String.normalized(): String { + // Normalize the string. + return Normalizer.normalize(this, Normalizer.Form.NFD) + // then keep only ASCII characters and replace non-ASCII characters with an empty string. + .replace("[^\\p{ASCII}]".toRegex(), "") +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt index 0099a67a79..c3cae14843 100644 --- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt @@ -74,4 +74,10 @@ class BasicExtensionsTest { val result = textWithDirectionOverrides.filterDirectionOverrides() assertEquals("123456789", result) } + + @Test + fun `normalized removes accents and replace special characters from text`() { + assertEquals("Stefan", "Ștefan".normalized()) + assertEquals("ca enleve etre", "ça enlève être".normalized()) + } } diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt index 3ecd091e0d..2a32c6d273 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt @@ -12,6 +12,7 @@ import android.os.Build import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.extensions.normalized import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.SdkMetadata @@ -26,11 +27,11 @@ class DefaultUserAgentProvider( override fun provide(): String = userAgent /** - * Create an user agent with the application version. + * Create a user agent with the application version. * Ex: Element X/1.5.0 (Xiaomi Mi 9T; Android 11; RKQ1.200826.002; Sdk c344b155c) */ private fun buildUserAgent(): String { - val appName = buildMeta.applicationName + val appName = buildMeta.applicationName.normalized() val appVersion = buildMeta.versionName val deviceManufacturer = Build.MANUFACTURER val deviceModel = Build.MODEL From ee1348e19e47bb51e80c9081789237b57b9eaf80 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:29:24 +0300 Subject: [PATCH 038/118] Give the room name in the timeline top bar a 48dp tap target (#7450) * Give the room name in the timeline top bar a 48dp tap target The row carrying the room avatar and name is the control that opens the room details, but it is only as tall as its content, which is under the 48dp minimum recommended for a touch target. Constrain it to a minimum height of 48dp. The children stay centred inside the app bar, so nothing moves visually. * Update screenshots The accessibility snapshot of the timeline is the only one the change affects: the node which opens the room details now reports a 48dp tall bounding box. The room name, the avatar and the icons stay where they were. --- .../features/messages/impl/topbars/MessagesViewTopBar.kt | 2 ++ .../images/features.messages.impl_MessagesViewA11y_en.png | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index 6aabf97f2c..d6a55c0563 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -84,6 +85,7 @@ internal fun MessagesViewTopBar( val roundedCornerShape = RoundedCornerShape(8.dp) Row( modifier = Modifier + .heightIn(min = 48.dp) .clip(roundedCornerShape) .clickable { onRoomDetailsClick() } .semantics { heading() }, diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png index f47e74ab7b..78a758b1e8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5823ae9a382421609200d569799505706180b94c6923f315caa81f8767ac58f8 -size 131340 +oid sha256:996251b2e0e0a28ec167ec137f913ed5bb6f21b443bbf8151bc3203ad853b667 +size 131374 From 6bd841d8381a4e38e2a40e58640fb91544e92af1 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:52:11 +0300 Subject: [PATCH 039/118] Close the invite screen once the invite has been declined (#7440) Declining an invite from the full screen invite left the user on that same screen, so they had to press back themselves to get to the room list. Both decline paths are affected: the plain decline handled by AcceptDeclineInviteView, and the decline and block sub screen, which navigated up into the join room flow instead of out of it. Navigate up from the join room flow on a successful decline, and give DeclineInviteAndBlockEntryPoint a callback so the sub screen can report a successful decline separately from a back press. The room list flow supplies the same callback, popping the sub screen once the invite has been declined. --- .../android/features/home/impl/HomeFlowNode.kt | 5 +++++ .../home/impl/DefaultHomeEntryPointTest.kt | 3 ++- .../DeclineInviteAndBlockEntryPoint.kt | 8 +++++++- .../impl/declineandblock/DeclineAndBlockNode.kt | 4 ++++ .../impl/declineandblock/DeclineAndBlockView.kt | 4 +++- .../DefaultDeclineAndBlockEntryPoint.kt | 3 ++- .../declineandblock/DeclineAndBlockViewTest.kt | 17 +++++++++++++++++ .../DefaultDeclineAndBlockEntryPointTest.kt | 9 ++++++++- .../FakeDeclineInviteAndBlockEntryPoint.kt | 1 + .../features/joinroom/impl/JoinRoomFlowNode.kt | 5 ++++- 10 files changed, 53 insertions(+), 6 deletions(-) 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 00f285e3f4..b7b05cca87 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 @@ -269,6 +269,11 @@ class HomeFlowNode( parentNode = this, buildContext = buildContext, inviteData = navTarget.inviteData, + callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() { + backstack.pop() + } + }, ) } is NavTarget.SelectNewOwnersWhenLeavingRoom -> { 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 8eee8e1ee2..5e8570adec 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 @@ -11,6 +11,7 @@ package io.element.android.features.home.impl import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat 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.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -36,7 +37,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, directLogoutView = { _ -> lambdaError() }, reportRoomEntryPoint = { _, _, _ -> lambdaError() }, - declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, + declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt index 4174d99222..3b9d512d80 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt @@ -10,13 +10,19 @@ package io.element.android.features.invite.api.declineandblock import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import io.element.android.features.invite.api.InviteData import io.element.android.libraries.architecture.FeatureEntryPoint -fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { +interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { fun createNode( parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: Callback, ): Node + + interface Callback : Plugin { + fun onDeclineSuccess() + } } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt index 2fe5c23088..89a2aaf238 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt @@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope @@ -31,6 +33,7 @@ class DeclineAndBlockNode( data class Inputs(val inviteData: InviteData) : NodeInputs private val inviteData = inputs().inviteData + private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback() private val presenter = presenterFactory.create(inviteData) @Composable @@ -39,6 +42,7 @@ class DeclineAndBlockNode( DeclineAndBlockView( state = state, onBackClick = ::navigateUp, + onDeclineSuccess = callback::onDeclineSuccess, modifier = modifier ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt index 1d14df024a..841034516e 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun DeclineAndBlockView( state: DeclineAndBlockState, onBackClick: () -> Unit, + onDeclineSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -53,7 +54,7 @@ fun DeclineAndBlockView( val isDeclining = state.declineAction is AsyncAction.Loading AsyncActionView( async = state.declineAction, - onSuccess = { onBackClick() }, + onSuccess = { onDeclineSuccess() }, errorMessage = { stringResource(CommonStrings.error_unknown) }, onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) }, onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) } @@ -149,5 +150,6 @@ internal fun DeclineAndBlockViewPreview( DeclineAndBlockView( state = state, onBackClick = {}, + onDeclineSuccess = {}, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt index d6e93fb805..4a5734af45 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt @@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: DeclineInviteAndBlockEntryPoint.Callback, ): Node { val inputs = DeclineAndBlockNode.Inputs(inviteData) - return parentNode.createNode(buildContext, plugins = listOf(inputs)) + return parentNode.createNode(buildContext, plugins = listOf(inputs, callback)) } } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt index 55c31a09d2..5cfb091e82 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.invite.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder @@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() { } } + @Test + fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setDeclineAndBlockView( + aDeclineAndBlockState( + declineAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onDeclineSuccess = it, + ) + } + } + @Test fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() { private fun AndroidComposeUiTest.setDeclineAndBlockView( state: DeclineAndBlockState, onBackClick: () -> Unit = EnsureNeverCalled(), + onDeclineSuccess: () -> Unit = EnsureNeverCalled(), ) { setContent { DeclineAndBlockView( state = state, onBackClick = onBackClick, + onDeclineSuccess = onDeclineSuccess, ) } } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt index 13bb13dccf..f09cda2337 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt @@ -11,7 +11,9 @@ package io.element.android.features.invite.impl.declineandblock import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.invite.test.anInviteData +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test @@ -31,12 +33,17 @@ class DefaultDeclineAndBlockEntryPointTest { ) } val inviteData = anInviteData() + val callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() = lambdaError() + } val result = entryPoint.createNode( parentNode = parentNode, buildContext = BuildContext.root(null), - inviteData = inviteData + inviteData = inviteData, + callback = callback, ) assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java) assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData)) + assertThat(result.plugins).contains(callback) } } diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt index 52d4d17790..19625f1473 100644 --- a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt @@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: DeclineInviteAndBlockEntryPoint.Callback, ): Node { lambdaError() } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt index 93f8f53412..493c180731 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt @@ -69,6 +69,9 @@ class JoinRoomFlowNode( parentNode = this, buildContext = buildContext, inviteData = navTarget.inviteData, + callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() = navigateUp() + }, ) NavTarget.Root -> rootNode(buildContext) } @@ -99,7 +102,7 @@ class JoinRoomFlowNode( acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, onAcceptInviteSuccess = {}, - onDeclineInviteSuccess = {}, + onDeclineInviteSuccess = { navigateUp() }, modifier = Modifier ) } From cf2ce2bd65fd9ea8231bda0cdc40d9d6aa3fe897 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 19 Aug 2026 13:00:27 +0200 Subject: [PATCH 040/118] Use Profiles sliding sync extension check for own profile subscription (#7403) * Use Profiles sliding sync extension check for own profile subscription --------- Co-authored-by: Jorge Martin Espinosa --- .../android/libraries/matrix/api/MatrixClient.kt | 3 +++ .../android/libraries/matrix/impl/RustMatrixClient.kt | 10 +++++++++- .../matrix/impl/fixtures/fakes/FakeFfiClient.kt | 3 +++ .../android/libraries/matrix/test/FakeMatrixClient.kt | 3 +++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 02ed16e097..3f98dcc509 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -245,6 +245,9 @@ interface MatrixClient : ClientUrlContentFetcher { /** Whether the homeserver advertises support for user status (MSC4426). */ suspend fun isUserStatusSupported(): Result + /** Whether the homeserver advertises support for the Profiles sliding sync extension (MSC4262). */ + suspend fun isProfilesSlidingSyncExtensionSupported(): Result + /** * Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. * 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 16371fcbf8..4be384d923 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 @@ -326,7 +326,9 @@ class RustMatrixClient( } private suspend fun setupUserProfile() { - val supported = isUserStatusSupported().getOrDefault(false) + // Subscribing to own profile updates only requires the Profiles sliding sync extension, + // not the full user status capability (which also needs the status profile field to be settable). + val supported = isProfilesSlidingSyncExtensionSupported().getOrDefault(false) if (supported) { // No need to seed the data here, it's already stored by the sdk. ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener) @@ -536,6 +538,12 @@ class RustMatrixClient( } } + override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isProfilesSlidingSyncExtensionSupported() + } + } + override fun enableAutomaticCallStatus(enabled: Boolean) { innerClient.enableAutomaticCallStatus(enabled) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index f09a350971..e91f8bdbed 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -61,6 +61,7 @@ class FakeFfiClient( private val accountDataResult: (String) -> String? = { lambdaError() }, private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() }, private val isUserStatusSupportedResult: () -> Boolean = { false }, + private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, private val getUrlResult: (String) -> ByteArray = { lambdaError() }, private val closeResult: () -> Unit = {}, @@ -103,6 +104,8 @@ class FakeFfiClient( override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult() + override suspend fun isProfilesSlidingSyncExtensionSupported(): Boolean = isProfilesSlidingSyncExtensionSupportedResult() + override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle { subscribeToOwnProfileResult(listener) return FakeFfiTaskHandle() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 6f330b6536..201f8cbf28 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -149,6 +149,7 @@ class FakeMatrixClient( var setUserStatusResult: Result = Result.success(Unit) var clearUserStatusResult: Result = Result.success(Unit) var isUserStatusSupportedResult: Result = Result.success(true) + var isProfilesSlidingSyncExtensionSupportedResult: Result = Result.success(true) private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) override val userProfile: StateFlow = _userProfile @@ -295,6 +296,8 @@ class FakeMatrixClient( override suspend fun isUserStatusSupported(): Result = isUserStatusSupportedResult + override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = isProfilesSlidingSyncExtensionSupportedResult + override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled) override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) From 7341feeed8907c0787a763eaeb37a066985e6187 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:44:11 +0300 Subject: [PATCH 041/118] Model the fake voice recorder on a cancellable recording job (#7466) * Model the fake voice recorder on a cancellable recording job FakeVoiceRecorder pushed a whole recording synchronously from a single suspending call, which made it both weaker and stricter than DefaultVoiceRecorder. Every level emission carried the same elapsed time and the emissions were coalesced by Molecule, so no intermediate recording state was observable and the level-limit test had to skip half the emissions to compensate. Each level now advances the test time source and suspends for its share of the recording duration, so the states arrive one at a time and that magic number is gone. Call verification moved to the constructor lambdas the rest of the repo uses, in place of hand-rolled counters and assertCalls. The counters discarded the argument, so nothing pinned that cancelling a recording stops it with cancelled = true; that is now asserted. Truth is no longer needed by the fixture module. A second startRecord() no longer raises an error where production only warns and returns, and stopping or deleting terminates an in-flight emission loop the way cancelling the real recording job does, so a stale recording state can no longer arrive after the recording has finished. * Keep each recording's level loop to its own recording The level loop checked a single isRecording flag, which cannot tell "this recording was cancelled" from "a later recording has started". After a cancel followed by a new start, the first loop resumed, saw the flag set again, and advanced the shared test time source, so the new recording reported the elapsed time of both. Each recording now carries an id and its loop stops as soon as that id is no longer the active one. --- ...efaultVoiceMessageComposerPresenterTest.kt | 100 +++++++++++++----- libraries/voicerecorder/test/build.gradle.kts | 1 - .../voicerecorder/test/FakeVoiceRecorder.kt | 78 ++++++-------- 3 files changed, 103 insertions(+), 76 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 7d3b6cf647..88d8ac38f1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -47,6 +47,7 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -60,6 +61,7 @@ import org.junit.Rule import org.junit.Test import java.io.File import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") @@ -67,8 +69,14 @@ class DefaultVoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + private val startRecordResult = lambdaRecorder { } + private val stopRecordResult = lambdaRecorder { } + private val deleteRecordingResult = lambdaRecorder { } private val voiceRecorder = FakeVoiceRecorder( - recordingDuration = RECORDING_DURATION + recordingDuration = RECORDING_DURATION, + startRecordResult = startRecordResult, + stopRecordResult = stopRecordResult, + deleteRecordingResult = deleteRecordingResult, ) private val analyticsService = FakeAnalyticsService() private val sendVoiceMessageResult = @@ -97,7 +105,9 @@ class DefaultVoiceMessageComposerPresenterTest { companion object { private val RECORDING_DURATION = 1.seconds + private val FIRST_LEVEL_DURATION = 500.milliseconds private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList()) + private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList()) } @Test @@ -106,7 +116,7 @@ class DefaultVoiceMessageComposerPresenterTest { presenter.test { val initialState = awaitItem() assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() testPauseAndDestroy(initialState) } @@ -118,9 +128,10 @@ class DefaultVoiceMessageComposerPresenterTest { presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + assertThat(awaitItem().voiceMessageState).isEqualTo(FIRST_RECORDING_STATE) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -133,14 +144,16 @@ class DefaultVoiceMessageComposerPresenterTest { val voiceRecorder = FakeVoiceRecorder( levels = levels, recordingDuration = RECORDING_DURATION, + startRecordResult = { }, + stopRecordResult = { }, + deleteRecordingResult = { }, ) val presenter = createDefaultVoiceMessageComposerPresenter( voiceRecorder = voiceRecorder, ) presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) - skipItems(numberOfLevels / 2 - 1) - val finalState = awaitItem() + val finalState = consumeItemsUntilTimeout().last() assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java) val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording // The number of levels should be limited to 128 items @@ -221,7 +234,8 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) - voiceRecorder.assertCalls(started = 1, stopped = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) cancelAndIgnoreRemainingEvents() } @@ -235,7 +249,9 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel)) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(true)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } } @@ -249,7 +265,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -265,8 +283,10 @@ class DefaultVoiceMessageComposerPresenterTest { } // Nothing should happen - assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0) + assertThat(finalState.voiceMessageState).isEqualTo(FIRST_RECORDING_STATE) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isNeverCalled() + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -282,7 +302,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) } - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -299,7 +321,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPausedState()) } - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -340,7 +364,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -360,7 +386,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -377,7 +405,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -455,7 +485,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -476,7 +508,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -499,7 +533,9 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true)) sendVoiceMessageResult.assertions().isNeverCalled() assertThat(analyticsService.trackedErrors).isEmpty() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -527,7 +563,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -577,7 +615,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isNeverCalled() assertThat(analyticsService.trackedErrors).hasSize(1) - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() testPauseAndDestroy(initialState) } @@ -586,8 +624,14 @@ class DefaultVoiceMessageComposerPresenterTest { @Test fun `present - record error - security exceptions are tracked`() = runTest { val exception = SecurityException("") - voiceRecorder.givenThrowsSecurityException(exception) - val presenter = createDefaultVoiceMessageComposerPresenter() + val startRecordResult = lambdaRecorder { throw exception } + val voiceRecorder = FakeVoiceRecorder( + recordingDuration = RECORDING_DURATION, + startRecordResult = startRecordResult, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + voiceRecorder = voiceRecorder, + ) presenter.test { val initialState = awaitItem() initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) @@ -596,7 +640,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(analyticsService.trackedErrors).containsExactly( VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) ) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(initialState) } @@ -616,7 +660,8 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) - voiceRecorder.assertCalls(stopped = 1) + startRecordResult.assertions().isNeverCalled() + stopRecordResult.assertions().isCalledOnce().with(value(false)) permissionsPresenter.setPermissionGranted() @@ -625,7 +670,8 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(stopped = 1, started = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) testPauseAndDestroy(finalState) } @@ -659,7 +705,7 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -696,7 +742,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(it.showPermissionRationaleDialog).isTrue() } - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() cancelAndIgnoreRemainingEvents() testPauseAndDestroy(finalState) diff --git a/libraries/voicerecorder/test/build.gradle.kts b/libraries/voicerecorder/test/build.gradle.kts index c0f74b71e8..f54ef0e8d3 100644 --- a/libraries/voicerecorder/test/build.gradle.kts +++ b/libraries/voicerecorder/test/build.gradle.kts @@ -19,6 +19,5 @@ dependencies { implementation(projects.tests.testutils) implementation(libs.coroutines.test) - implementation(libs.test.truth) implementation(projects.libraries.core) } diff --git a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt index ec62227857..b735bb9fa5 100644 --- a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt +++ b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt @@ -8,13 +8,13 @@ package io.element.android.libraries.voicerecorder.test -import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.yield import java.io.File import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -23,51 +23,47 @@ import kotlin.time.TestTimeSource class FakeVoiceRecorder( private val timeSource: TestTimeSource = TestTimeSource(), private val recordingDuration: Duration = 0.seconds, - private val levels: List = listOf(0.1f, 0.2f) + private val levels: List = listOf(0.1f, 0.2f), + val waveform: List = A_WAVEFORM, + private val startRecordResult: () -> Unit = { lambdaError() }, + private val stopRecordResult: (Boolean) -> Unit = { lambdaError() }, + private val deleteRecordingResult: () -> Unit = { lambdaError() }, ) : VoiceRecorder { private val _state = MutableStateFlow(VoiceRecorderState.Idle) override val state: StateFlow = _state - private var curRecording: File? = null + private val levelInterval = if (levels.isEmpty()) Duration.ZERO else recordingDuration / levels.size - private var securityException: SecurityException? = null + private var currentRecording: File? = null + private var lastRecordingId = 0 + private var activeRecordingId = 0 - private var startedCount = 0 - private var stoppedCount = 0 - private var deletedCount = 0 - - var waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f) override suspend fun startRecord() { - startedCount += 1 + startRecordResult() + if (activeRecordingId != 0) return + val recordingId = ++lastRecordingId + activeRecordingId = recordingId + currentRecording = File("file.ogg") val startedAt = timeSource.markNow() - securityException?.let { throw it } - - if (curRecording != null) { - error("Previous recording was not cleared") - } - curRecording = File("file.ogg") - - timeSource += recordingDuration for (i in 1..levels.size) { + delay(levelInterval) + if (activeRecordingId != recordingId) return + timeSource += levelInterval _state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i))) - yield() } } - override suspend fun stopRecord( - cancelled: Boolean - ) { - stoppedCount++ - + override suspend fun stopRecord(cancelled: Boolean) { + stopRecordResult(cancelled) + activeRecordingId = 0 if (cancelled) { deleteRecording() } - _state.emit( - when (curRecording) { + when (val file = currentRecording) { null -> VoiceRecorderState.Idle else -> VoiceRecorderState.Finished( - file = curRecording!!, + file = file, mimeType = MimeTypes.Ogg, duration = recordingDuration, waveform = waveform, @@ -77,25 +73,11 @@ class FakeVoiceRecorder( } override suspend fun deleteRecording() { - deletedCount++ - curRecording = null - - _state.emit( - VoiceRecorderState.Idle - ) - } - - fun assertCalls( - started: Int = 0, - stopped: Int = 0, - deleted: Int = 0, - ) { - assertThat(startedCount).isEqualTo(started) - assertThat(stoppedCount).isEqualTo(stopped) - assertThat(deletedCount).isEqualTo(deleted) - } - - fun givenThrowsSecurityException(exception: SecurityException) { - this.securityException = exception + deleteRecordingResult() + activeRecordingId = 0 + currentRecording = null + _state.emit(VoiceRecorderState.Idle) } } + +private val A_WAVEFORM = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f) From 7adbd3941aa4371ffc08e8309bdd12f27f0cc86f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 19 Aug 2026 14:20:31 +0200 Subject: [PATCH 042/118] Make the PUSHER_APP_ID configurable at build time. --- .../element/android/appconfig/PushConfig.kt | 16 ---------- libraries/push/impl/build.gradle.kts | 29 +++++++++++++++++++ .../push/impl/DefaultPusherSubscriber.kt | 5 ++-- .../libraries/push/impl/test/TestPush.kt | 4 +-- .../push/impl/DefaultPusherSubscriberTest.kt | 5 ++-- .../push/impl/test/DefaultTestPushTest.kt | 4 +-- .../src/main/kotlin/config/BuildTimeConfig.kt | 3 ++ 7 files changed, 40 insertions(+), 26 deletions(-) delete mode 100644 appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt deleted file mode 100644 index d36fb742b0..0000000000 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.appconfig - -object PushConfig { - /** - * Note: pusher_app_id cannot exceed 64 chars. - */ - const val PUSHER_APP_ID: String = "im.vector.app.android" -} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index b9082c50f6..8615485bba 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -1,3 +1,5 @@ +import config.BuildTimeConfig +import extension.buildConfigFieldStr import extension.setupDependencyInjection import extension.testCommonDependencies @@ -17,6 +19,33 @@ plugins { android { namespace = "io.element.android.libraries.push.impl" + buildFeatures { + buildConfig = true + } + + buildTypes { + val defaultPusherAppId = "im.vector.app.android" + getByName("release") { + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_RELEASE ?: defaultPusherAppId, + ) + } + getByName("debug") { + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_DEBUG ?: defaultPusherAppId, + ) + } + register("nightly") { + matchingFallbacks += listOf("release") + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_NIGHTLY ?: defaultPusherAppId, + ) + } + } + testOptions { unitTests { isIncludeAndroidResources = true diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt index 35482aa164..7aa519a791 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appconfig.PushConfig import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta @@ -74,7 +73,7 @@ class DefaultPusherSubscriber( ): SetHttpPusherData = SetHttpPusherData( pushKey = pushKey, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, // TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()) profileTag = DEFAULT_PUSHER_FILE_TAG + "_", // TODO localeProvider.current().language @@ -104,7 +103,7 @@ class DefaultPusherSubscriber( .unsetHttpPusher( unsetHttpPusherData = UnsetHttpPusherData( pushKey = pushKey, - appId = PushConfig.PUSHER_APP_ID + appId = BuildConfig.PUSHER_APP_ID ) ) .onSuccess { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt index 4ed8ca8338..6921c050db 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -10,9 +10,9 @@ package io.element.android.libraries.push.impl.test import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appconfig.PushConfig import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.BuildConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.api.Config @@ -28,7 +28,7 @@ class DefaultTestPush( pushGatewayNotifyRequest.execute( PushGatewayNotifyRequest.Params( url = config.url, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, pushKey = config.pushKey, eventId = TEST_EVENT_ID, roomId = TEST_ROOM_ID, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt index 8cb1e2a7f6..76479720be 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.push.impl import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.PushConfig import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData @@ -96,7 +95,7 @@ class DefaultPusherSubscriberTest { value( SetHttpPusherData( pushKey = "aPushKey", - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, url = "aGateway", appDisplayName = "MyApp", deviceDisplayName = "MyDevice", @@ -163,7 +162,7 @@ class DefaultPusherSubscriberTest { value( UnsetHttpPusherData( pushKey = "aPushKey", - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, ), ) ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt index 9ab0f16b62..3722c09d99 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -8,7 +8,7 @@ package io.element.android.libraries.push.impl.test -import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.BuildConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.test.aSessionPushConfig import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -33,7 +33,7 @@ class DefaultTestPushTest { value( PushGatewayNotifyRequest.Params( url = aConfig.url, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, pushKey = aConfig.pushKey, eventId = DefaultTestPush.TEST_EVENT_ID, roomId = DefaultTestPush.TEST_ROOM_ID, diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index e31ffea658..4160efeb78 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -31,4 +31,7 @@ object BuildTimeConfig { val BUG_REPORT_APP_NAME: String? = null const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true + val PUSHER_APP_ID_RELEASE: String? = null + val PUSHER_APP_ID_DEBUG: String? = null + val PUSHER_APP_ID_NIGHTLY: String? = null } From a70cd18563ec2d25289a6f28d10ce5b8eb6e7615 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:52 +0000 Subject: [PATCH 043/118] Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 --- 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 a5ebc5b4e7..6686b7bd06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.17" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } From f2114d94f104f414604a7cba95df39554745f30c Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 19 Aug 2026 14:05:29 +0000 Subject: [PATCH 044/118] Update screenshots --- ...impl.accountprovider_AccountProviderOtherView_Day_0_en.png | 3 --- ...pl.accountprovider_AccountProviderOtherView_Night_0_en.png | 3 --- ...angeaccountprovider_ChangeAccountProviderView_Day_0_en.png | 3 --- ...angeaccountprovider_ChangeAccountProviderView_Day_1_en.png | 3 --- ...geaccountprovider_ChangeAccountProviderView_Night_0_en.png | 3 --- ...geaccountprovider_ChangeAccountProviderView_Night_1_en.png | 3 --- ...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 | 3 +++ ...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 | 3 +++ ....impl.screens.createaccount_CreateAccountView_Day_0_en.png | 3 --- ....impl.screens.createaccount_CreateAccountView_Day_1_en.png | 3 --- ....impl.screens.createaccount_CreateAccountView_Day_2_en.png | 3 --- ....impl.screens.createaccount_CreateAccountView_Day_3_en.png | 3 --- ...mpl.screens.createaccount_CreateAccountView_Night_0_en.png | 3 --- ...mpl.screens.createaccount_CreateAccountView_Night_1_en.png | 3 --- ...mpl.screens.createaccount_CreateAccountView_Night_2_en.png | 3 --- ...mpl.screens.createaccount_CreateAccountView_Night_3_en.png | 3 --- ...archaccountprovider_SearchAccountProviderView_Day_0_en.png | 3 --- ...archaccountprovider_SearchAccountProviderView_Day_1_en.png | 3 --- ...chaccountprovider_SearchAccountProviderView_Night_0_en.png | 3 --- ...chaccountprovider_SearchAccountProviderView_Night_1_en.png | 3 --- 26 files changed, 18 insertions(+), 66 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png deleted file mode 100644 index c9bf9e3c1c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0990301d92d4862054ba5e54103dac359df37997242498766854d7bb13431657 -size 6068 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png deleted file mode 100644 index fb829c4873..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3022b2b813d9a3f77b0ba72706f0fb870a1c3f7b4a8efc90e611b66b6e9bdf8e -size 5926 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png deleted file mode 100644 index 23858ce575..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c7544e2566517e8692a1cccd4607bc2ab0f2767bbe9e5b39eb439d7695ad2ce5 -size 46905 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png deleted file mode 100644 index 9b505f3cf9..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b5dbf45467305e83f302fd626a2c0adcd27eac6aeea8b7a83efdb6e728d974da -size 44703 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png deleted file mode 100644 index 815986cbc3..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:41907de78c86fca804af2af7dd351db6a1ab87515e71bb906ffa2219ad4f5879 -size 45880 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png deleted file mode 100644 index f08662715a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:62581b532695d10a42432dfcb37249c28c2bd617ecb49d7f3a8224855b78ae55 -size 43600 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 721b1f8389..caa1dc1cbb 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:9fb394bf8c063e5b4457fc75c171849b1ae7f435941e0d86d09298a846595861 -size 35160 +oid sha256:da11677aad93fcf9461c62a0fb9a1f384950dc69f0666914da5785265d56a4dd +size 35877 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 3d04fae696..cb6f870735 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:4626f30754044cee4438c44f8972f1b643342aea9c7b38f3650874da8c7e7614 -size 37303 +oid sha256:99d28d3547d50e9ae23b012521ef5f8a157aa4b3a8a98b3e138847652f8cacff +size 35761 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 c0d9a71d1d..d9eaefae3c 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:ccaed5beab96b32c7ba783136d1fe8483697de44d0ed7c7555cbef9dd3708e66 -size 38953 +oid sha256:84954d900742e981811f1471fbe175f57c927ffc4da9e801d5e5b48f073dc13a +size 39610 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 new file mode 100644 index 0000000000..aa987474a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a524b0538ad4204ea66c46c139035671f55d874c45d46476fb885c15879b93f2 +size 31806 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 0127885711..0b0b5f123c 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:5ae87930bb1123d432f7f4e2792ad1e372ec383f968ad335b4f2f069b8faa307 -size 34294 +oid sha256:bb5607ed2e0986d5035120a2e6276ff33e65a7bafa5325a0c0c22f1975b69c2b +size 34986 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 b15dcf0142..4da08d87a3 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:787b3708bfc0bebd19cde31589e175ee0ea593c4bd1461b9593e9705947e6ffc -size 36280 +oid sha256:80c7e798346755382f913f72da9b18fc200867280e6f33adde3622a964f7d2d7 +size 34883 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 b9046df90d..3d6c836738 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:e8d1a1013e5eaed74889193e4c6551ae5afa96a1d10fa3d32de3dc96a0c1ae13 -size 36582 +oid sha256:cbe35c9bb9ec509b64a2ce27afa879b8904269ae8f9afbd5ac8a91098d7fccb3 +size 38777 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 new file mode 100644 index 0000000000..bada75332c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efa31bac320466c41fbf33b462418716c1f4fb6def584b38834320d213d2c819 +size 29777 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png deleted file mode 100644 index 34cf030cba..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:09bf68ea40f2b1c109b015b8e9e9c767931ef70936a956652ac649b8e5a2561e -size 13491 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png deleted file mode 100644 index 67186daea6..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:323218a1ce6e4afaf4632fed871cbc6e17ed6686243523aeb9da0d81a7825d31 -size 13602 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png deleted file mode 100644 index c0cc08611c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:193e7af1adc71d5e1133694e234671ebb6fefe3766c9891733c3ae46a999b1cd -size 13655 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png deleted file mode 100644 index 26e32e2174..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb76a05a00c3e41c7edaa853db6d3773fddfb23c750eea4f45d4f4b1acfe5447 -size 15466 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png deleted file mode 100644 index da53968272..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a153cb8df9aadaa9cee0fef7f140e43f33851f18815674edc9a9682a7c601d2 -size 12942 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png deleted file mode 100644 index 5df3ef308b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cba9108df3dae3f9968825e13528087fdf5d8b70c03faabbf0b06e276392a7d7 -size 13048 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png deleted file mode 100644 index 91a5e6418a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:79cd361b36c8be418871a8cf6773bcc8f3626fc8d09012340f6d245ea6b0574e -size 12967 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png deleted file mode 100644 index c6b38f80b0..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9063e4d7b900c397dacd964c25ce42865e292ea2a5a41f37cb7dfabd86f9246f -size 14142 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png deleted file mode 100644 index 86e3a6a0c1..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64ca7f84a90416cac5161e379079edb3edadf1f82240214974a3216cd5d41090 -size 23418 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png deleted file mode 100644 index 583a7beb59..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd5189b1079f9011162ff7ba2c9211fbfff543db892b86ea3b57ec78495d2019 -size 50344 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png deleted file mode 100644 index fd9fce9c5c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5122ee1af4ff4ddc5e4c766f992e9833e4c1329bc634e82b0e52743326328447 -size 22757 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png deleted file mode 100644 index 1be9bd5d99..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96742102f57f2e58027e3402ea01c91ee1eab6010dc46ef8ff8ee3175264a628 -size 49259 From 42f9e15641375c1bafd5004cc2c81837e2a91398 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 19 Aug 2026 17:16:19 +0200 Subject: [PATCH 045/118] Move strings to the login module --- .../ConfirmAccountProviderView.kt | 13 +++++++------ .../impl/src/main/res/values-es/translations.xml | 8 ++++++++ .../impl/src/main/res/values-et/translations.xml | 8 ++++++++ .../impl/src/main/res/values-fi/translations.xml | 8 ++++++++ .../impl/src/main/res/values-fr/translations.xml | 8 ++++++++ .../impl/src/main/res/values-in/translations.xml | 8 ++++++++ .../impl/src/main/res/values-ja/translations.xml | 8 ++++++++ .../impl/src/main/res/values-pl/translations.xml | 8 ++++++++ .../impl/src/main/res/values-ro/translations.xml | 8 ++++++++ .../impl/src/main/res/values-uk/translations.xml | 8 ++++++++ .../impl/src/main/res/values-zh/translations.xml | 8 ++++++++ .../login/impl/src/main/res/values/localazy.xml | 8 ++++++++ .../src/main/res/values-es/translations.xml | 8 -------- .../src/main/res/values-et/translations.xml | 8 -------- .../src/main/res/values-fi/translations.xml | 8 -------- .../src/main/res/values-fr/translations.xml | 8 -------- .../src/main/res/values-pl/translations.xml | 8 -------- .../src/main/res/values-ro/translations.xml | 8 -------- .../src/main/res/values-uk/translations.xml | 8 -------- .../src/main/res/values-zh/translations.xml | 8 -------- .../ui-strings/src/main/res/values/localazy.xml | 8 -------- tools/localazy/config.json | 1 + 22 files changed, 96 insertions(+), 78 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index c81fd529ef..e206b624cf 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeView @@ -120,9 +121,9 @@ fun ConfirmAccountProviderView( iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { - CommonStrings.screen_change_server_title_register + R.string.screen_change_server_title_register } else { - CommonStrings.screen_change_server_title_login + R.string.screen_change_server_title_login } ), subTitle = null, @@ -157,13 +158,13 @@ fun ConfirmAccountProviderView( .padding(top = 40.dp) .focusRequester(focusRequester) .testTag(TestTags.changeServerServer), - label = stringResource(id = CommonStrings.screen_change_server_textfield_header), - placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder), + label = stringResource(id = R.string.screen_change_server_textfield_header), + placeholder = stringResource(id = R.string.screen_change_server_textfield_placeholder), supportingText = stringResource( id = if (state.isAccountCreation) { - CommonStrings.screen_change_server_textfield_footer_register + R.string.screen_change_server_textfield_footer_register } else { - CommonStrings.screen_change_server_textfield_footer_login + R.string.screen_change_server_textfield_footer_login } ), visualTransformation = ghostTransformation, diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index 33b3741234..13a93164a6 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -25,8 +25,16 @@ "No se permite el proveedor de cuentas %1$s." "URL del servidor base" "Introduce una dirección de dominio." + "Iniciar sesión" + "Crear cuenta" "¿Cuál es la dirección de tu servidor?" + "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)." + "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)." + "Proveedor de cuentas" + "ejemplo.com" "Selecciona tu servidor" + "Introduce tu proveedor de cuenta" + "Elegir proveedor de cuentas" "Crear cuenta" "Esta cuenta ha sido eliminada." "Usuario y/o contraseña incorrectos" diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml index dc37ed3e38..a27bc18e3e 100644 --- a/features/login/impl/src/main/res/values-et/translations.xml +++ b/features/login/impl/src/main/res/values-et/translations.xml @@ -25,8 +25,16 @@ "%1$s teenusepakkuja pole lubatud." "Koduserveri url" "Sisesta domeeni aadress." + "Logi sisse" + "Loo kasutajakonto" "Mis on sinu koduserveri aadress?" + "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)." + "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)." + "Teenusepakkuja" + "domeeninäidis.ee" "Vali oma server" + "Sisesta oma kasutajakonto teenusepakkuja" + "Vali kasutajakonto teenusepakkuja" "Loo kasutajakonto" "See kasutajakonto on kustutatud." "Vigane kasutajanimi ja/või salasõna" diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml index 8561bcfce3..493ddee2f8 100644 --- a/features/login/impl/src/main/res/values-fi/translations.xml +++ b/features/login/impl/src/main/res/values-fi/translations.xml @@ -25,8 +25,16 @@ "Palveluntarjoaja %1$s ei ole sallittu." "Kotipalvelimen osoite" "Anna verkkotunnuksen osoite." + "Kirjaudu sisään" + "Luo tili" "Mikä on palvelimesi osoite?" + "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)." + "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)." + "Palveluntarjoaja" + "example.com" "Valitse palvelimesi" + "Anna palveluntarjoajasi" + "Valitse palveluntarjoaja" "Luo tili" "Tämä tili on poistettu." "Väärä käyttäjänimi ja/tai salasana" diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml index 504517aad7..0af658d34f 100644 --- a/features/login/impl/src/main/res/values-fr/translations.xml +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -25,8 +25,16 @@ "Le fournisseur de compte %1$s n’est pas autorisé." "URL du serveur d’accueil" "Saisissez une adresse de domaine." + "Se connecter" + "Créer un compte" "Quelle est l’adresse de votre serveur ?" + "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)." + "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)." + "Fournisseur de compte" + "exemple.com" "Choisissez votre serveur" + "Saisissez votre fournisseur de compte" + "Choisissez un fournisseur de compte" "Créer un compte" "Ce compte a été supprimé." "Nom d’utilisateur et/ou mot de passe incorrects" diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml index 1d29c5b619..34ba1bd0ed 100644 --- a/features/login/impl/src/main/res/values-in/translations.xml +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -25,8 +25,16 @@ "Penyedia akun %1$s tidak diizinkan." "URL Homeserver" "Masukkan alamat domain." + "Login" + "Buat akun" "Apa alamat server Anda?" + "Jika akun Anda disediakan oleh perusahaan atau komunitas Anda, maka ubah penyedia akun (contoh: companyname.com)." + "Buat akun di matrix.org secara gratis. Jika anda ingin membuat akun dengan penyedia lain, ubah sesuai kebutuhan (contoh: communityname.org)." + "Penyedia akun" + "contoh.com" "Pilih server Anda" + "Masukkan penyedia akun Anda" + "Pilih penyedia akun" "Buat akun" "Akun ini telah dinonaktifkan." "Nama pengguna dan/atau kata sandi salah" diff --git a/features/login/impl/src/main/res/values-ja/translations.xml b/features/login/impl/src/main/res/values-ja/translations.xml index 42698e27dc..b4d6f16721 100644 --- a/features/login/impl/src/main/res/values-ja/translations.xml +++ b/features/login/impl/src/main/res/values-ja/translations.xml @@ -24,8 +24,16 @@ "アカウント提供元 %1$s は許可されていません。" "ホームサーバーURL" "ドメイン名を入力してください" + "サインイン" + "アカウントを作成" "サーバーのアドレスは何ですか?" + "アカウントが会社やコミュニティから発行されている場合は、アカウント提供元を変更してください (例: kaisha-mei.com)。" + "matrix.org 上に無料でアカウントを作成できます。異なるアカウント提供元上にアカウントを作成したい場合は、それにならって変更してください (例: dantai-mei.org)。" + "アカウント提供元" + "example.com" "サーバーを選択" + "アカウント提供元を入力" + "アカウント提供元を選択" "アカウントを作成" "アカウントは削除されました。" "ユーザー名またはパスワードが違います" diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml index 634d856cdf..81d8570e32 100644 --- a/features/login/impl/src/main/res/values-pl/translations.xml +++ b/features/login/impl/src/main/res/values-pl/translations.xml @@ -25,8 +25,16 @@ "Dostawca konta %1$s jest niedozwolony." "URL serwera domowego" "Wprowadź adres domeny." + "Zaloguj" + "Utwórz konto" "Jaki jest adres Twojego serwera?" + "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)" + "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)." + "Dostawca konta" + "przykład.com" "Wybierz swój serwer" + "Wprowadź dostawcę swojego konta" + "Wybierz dostawcę konta" "Utwórz konto" "To konto zostało usunięte." "Nieprawidłowa nazwa użytkownika i/lub hasło" diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index b44242abf8..6835c0a15d 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -25,8 +25,16 @@ "Furnizorul de cont %1$s nu este permis." "Adresa URL a homeserver-ului" "Introduceți o adresă de domeniu." + "Autentificare" + "Creați un cont" "Care este adresa serverului dumneavoastră?" + "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)." + "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)." + "Furnizor de cont" + "exemplu.ro" "Selectați serverul dumneavoastra" + "Introduceți furnizorul contului dumneavoastră" + "Alegeți un furnizor de cont" "Creați un cont" "Acest cont a fost șters." "Utilizator și/sau parolă incorecte" diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml index 60959c80d4..918caf5530 100644 --- a/features/login/impl/src/main/res/values-uk/translations.xml +++ b/features/login/impl/src/main/res/values-uk/translations.xml @@ -25,8 +25,16 @@ "Постачальник облікового запису %1$s не дозволений." "URL-адреса домашнього сервера" "Введіть адресу домену." + "Увійти" + "Створити обліковий запис" "Яка адреса вашого сервера?" + "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)." + "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)." + "Постачальник облікового запису" + "example.com" "Виберіть свій сервер" + "Вкажіть постачальника вашого облікового запису" + "Оберіть постачальника облікового запису" "Створити обліковий запис" "Цей обліковий запис було видалено." "Неправильне ім\'я користувача та/або пароль" diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml index 16afe7d5bd..85de302dc1 100644 --- a/features/login/impl/src/main/res/values-zh/translations.xml +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -25,8 +25,16 @@ "账户提供者 %1$s 不被允许。" "主服务器 URL" "输入域名地址。" + "登录" + "创建账户" "你的服务器地址是什么?" + "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。" + "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。" + "账户提供者" + "example.com" "选择服务器" + "输入你的账户提供者" + "选择账户提供者" "创建账户" "此账户已被删除。" "用户名与(或)密码不正确" diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 10fb6ef04a..7e798081f5 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -25,8 +25,16 @@ "Account provider %1$s not allowed." "Homeserver URL" "Enter a domain address." + "Sign in" + "Create account" "What is the address of your server?" + "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." + "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." + "Account provider" + "example.com" "Select your server" + "Enter your account provider" + "Choose an account provider" "Create account" "This account has been deleted." "Incorrect username and/or password" diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index 223b8aa457..8a28af1e9c 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -516,14 +516,6 @@ Motivo: %1$s." "%1$s Android" "Agitar con fuerza para informar de un error" "Captura de pantalla" - "Iniciar sesión" - "Crear cuenta" - "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)." - "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)." - "Proveedor de cuentas" - "ejemplo.com" - "Introduce tu proveedor de cuenta" - "Elegir proveedor de cuentas" "%1$s: %2$s" "Opciones" "Eliminar %1$s" diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index 14800b9ca3..c24e8652f6 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -507,14 +507,6 @@ Kas sa oled kindel, et soovid jätkata?" "%1$s Android" "Veast teatamiseks raputa nutiseadet ägedalt" "Ekraanitõmmis" - "Logi sisse" - "Loo kasutajakonto" - "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)." - "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)." - "Teenusepakkuja" - "domeeninäidis.ee" - "Sisesta oma kasutajakonto teenusepakkuja" - "Vali kasutajakonto teenusepakkuja" "%1$s: %2$s" "Valikud" "Kustuta: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index b1cc4889a8..11ca38edf0 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -516,14 +516,6 @@ Haluatko varmasti jatkaa?" "%1$s Android" "Raivostunut ravistaminen ilmoittaa virheestä" "Näyttökuva" - "Kirjaudu sisään" - "Luo tili" - "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)." - "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)." - "Palveluntarjoaja" - "example.com" - "Anna palveluntarjoajasi" - "Valitse palveluntarjoaja" "%1$s: %2$s" "Vaihtoehdot" "Poista %1$s" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 105ced1ad5..6aced99b00 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -516,14 +516,6 @@ Raison : %1$s." "%1$s Android" "Rageshake pour signaler un problème" "Capture d’écran" - "Se connecter" - "Créer un compte" - "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)." - "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)." - "Fournisseur de compte" - "exemple.com" - "Saisissez votre fournisseur de compte" - "Choisissez un fournisseur de compte" "%1$s: %2$s" "Options" "Supprimer %1$s" diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 1ee4275703..f0ac7785da 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -528,14 +528,6 @@ Czy na pewno chcesz kontynuować?" "%1$s Android" "Wstrząśnij gniewnie, aby zgłosić błąd" "Zrzut ekranu" - "Zaloguj" - "Utwórz konto" - "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)" - "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)." - "Dostawca konta" - "przykład.com" - "Wprowadź dostawcę swojego konta" - "Wybierz dostawcę konta" "%1$s: %2$s" "Opcje" "Usuń %1$s" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 71b5ab7041..cd18373e6a 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -528,14 +528,6 @@ Sunteți sigur că doriți să continuați?" "%1$s Android" "Rageshake pentru a raporta erori" "Captură de ecran" - "Autentificare" - "Creați un cont" - "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)." - "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)." - "Furnizor de cont" - "exemplu.ro" - "Introduceți furnizorul contului dumneavoastră" - "Alegeți un furnizor de cont" "%1$s: %2$s" "Opțiuni" "Ștergeți %1$s" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index 8545325603..e00a269473 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -528,14 +528,6 @@ "%1$s Android" "Повідомляти про помилку потрусивши телефон" "Знімок екрана" - "Увійти" - "Створити обліковий запис" - "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)." - "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)." - "Постачальник облікового запису" - "example.com" - "Вкажіть постачальника вашого облікового запису" - "Оберіть постачальника облікового запису" "%1$s: %2$s" "Варіанти" "Вилучити %1$s" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index b363ba9498..03929c0182 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -504,14 +504,6 @@ "%1$s Android" "摇一摇以报告 bug" "屏幕截图" - "登录" - "创建账户" - "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。" - "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。" - "账户提供者" - "example.com" - "输入你的账户提供者" - "选择账户提供者" "%1$s:%2$s" "选项" "移除 %1$s" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 0d27ff5a9f..92899ba9da 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -516,14 +516,6 @@ Are you sure you want to continue?" "%1$s Android" "Rageshake to report bug" "Screenshot" - "Sign in" - "Create account" - "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." - "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." - "Account provider" - "example.com" - "Enter your account provider" - "Choose an account provider" "%1$s: %2$s" "Options" "Remove %1$s" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 8a7790be1e..061f79f7f3 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -173,6 +173,7 @@ "includeRegex" : [ "screen_onboarding_.*", "screen\\.onboarding\\..*", + "screen\\.change_server\\..*", "screen\\.missing_key_backup\\..*", "screen_login_.*", "screen_server_confirmation_.*", From 2432b0ccc6d832f85d0c69876cabf1ef34c09edf Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:55:03 +0300 Subject: [PATCH 046/118] Keep playing a voice message when another app plays a short sound (#7454) * Request media audio focus when playing a voice message Voice message playback asked for audio focus with USAGE_VOICE_COMMUNICATION, the same bucket as Element Call and voice message recording, while the player itself renders on the media stream. Audio policy layers act on the declared usage, so a car head unit switches to its call audio context and routes the playback away while the player keeps running. Declare USAGE_MEDIA (and STREAM_MUSIC on the legacy path) for playback, which is what the player actually renders. Recording and Element Call keep asking for voice communication focus. * Keep playing a voice message through a short sound from another app A notification, a navigation prompt or any other app taking focus for a moment sends AUDIOFOCUS_LOSS_TRANSIENT or AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, and DefaultMediaPlayer pauses on it. The voice message stops part way through and the user has to find it again and restart it. Recording already ignores those two, because the capture path is not affected by focus. Give voice message playback the same treatment, moving the requester test into a named function rather than growing the inline condition. A permanent AUDIOFOCUS_LOSS, a real phone call, still pauses, and the media viewer is untouched. --- libraries/audio/impl/build.gradle.kts | 3 + .../libraries/audio/impl/DefaultAudioFocus.kt | 19 +++-- .../audio/impl/DefaultAudioFocusTest.kt | 80 +++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts index 419e81962c..c0db3c4f86 100644 --- a/libraries/audio/impl/build.gradle.kts +++ b/libraries/audio/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -22,4 +23,6 @@ dependencies { implementation(libs.androidx.corektx) implementation(projects.libraries.di) + + testCommonDependencies(libs) } diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt index bc6a42d2f9..a9bc6fdbfd 100644 --- a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt +++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt @@ -48,9 +48,8 @@ class DefaultAudioFocus( } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - // For recording, ignore transient focus losses (e.g., notification sounds). - // The AudioRecord API keeps capturing regardless. - if (requester != AudioFocusRequester.RecordVoiceMessage) { + Timber.d("AudioFocus: transient loss ($it)") + if (requester.pausesOnTransientFocusLoss()) { onFocusLost() } } @@ -96,8 +95,8 @@ class DefaultAudioFocus( private fun AudioFocusRequester.toAudioUsage(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage, AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioFocusRequester.VoiceMessage, AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA } } @@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int { private fun AudioFocusRequester.toAudioStream(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage, AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL + AudioFocusRequester.VoiceMessage, AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC } } +private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean { + return when (this) { + // The AudioRecord API keeps capturing regardless. + AudioFocusRequester.RecordVoiceMessage, + AudioFocusRequester.VoiceMessage -> false + AudioFocusRequester.ElementCall, + AudioFocusRequester.MediaViewer -> true + } +} + private fun AudioFocusRequester.willPausedWhenDucked(): Boolean { return when (this) { // (note that for Element Call, there is no action when the focus is lost) diff --git a/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt new file mode 100644 index 0000000000..d0e255dd41 --- /dev/null +++ b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * Copyright 2026 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.audio.impl + +import android.media.AudioAttributes +import android.media.AudioManager +import androidx.core.content.getSystemService +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +class DefaultAudioFocusTest : RobolectricTest() { + @Test + fun `voice message playback requests focus for media, not for a call`() { + assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA) + } + + @Test + fun `media viewer playback requests focus for media`() { + assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA) + } + + @Test + fun `voice message recording requests focus for voice communication`() { + assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION) + } + + @Test + fun `element call requests focus for voice communication`() { + assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION) + } + + @Test + fun `a transient focus loss does not pause a voice message`() { + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse() + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse() + } + + @Test + fun `a permanent focus loss pauses a voice message`() { + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue() + } + + @Test + fun `a transient focus loss does not stop a voice message recording`() { + assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse() + } + + @Test + fun `a transient focus loss still pauses the media viewer`() { + assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue() + } + + private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean { + val context = RuntimeEnvironment.getApplication() + val audioManager = requireNotNull(context.getSystemService()) + var focusLost = false + DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true } + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + request.listener.onAudioFocusChange(focusChange) + return focusLost + } + + private fun usageOf(requester: AudioFocusRequester): Int { + val context = RuntimeEnvironment.getApplication() + val audioManager = requireNotNull(context.getSystemService()) + DefaultAudioFocus(context).requestAudioFocus(requester) {} + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + return requireNotNull(request.audioFocusRequest).audioAttributes.usage + } +} From c362bca5270f88d8d64b55c914ae6be849c7e404 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:55:50 +0300 Subject: [PATCH 047/118] Keep the send button when the composer is emptied while editing a message (#7456) * Keep the send button when the composer is emptied while editing rememberEndButtonParams checked whether the composer had sendable text before it checked whether the user was editing a message, so clearing the text of a message being edited replaced the send button with the voice recorder button. Starting a recording from there abandons the edit. Check the editing branch first, as the formatting composer already does, and let it render a disabled send button when there is nothing to send. * Update screenshots --- .../libraries/textcomposer/TextComposer.kt | 26 +++--- .../textcomposer/TextComposerEndButtonTest.kt | 85 +++++++++++++++++++ ...oser_MarkdownTextComposerEdit_Day_0_en.png | 4 +- ...er_MarkdownTextComposerEdit_Night_0_en.png | 4 +- ...mposer_TextComposerAddCaption_Day_0_en.png | 4 +- ...oser_TextComposerAddCaption_Night_0_en.png | 4 +- ...poser_TextComposerEditCaption_Day_0_en.png | 4 +- ...ser_TextComposerEditCaption_Night_0_en.png | 4 +- ..._TextComposerEditNotEncrypted_Day_0_en.png | 4 +- ...extComposerEditNotEncrypted_Night_0_en.png | 4 +- ...textcomposer_TextComposerEdit_Day_0_en.png | 4 +- ...xtcomposer_TextComposerEdit_Night_0_en.png | 4 +- 12 files changed, 119 insertions(+), 32 deletions(-) create mode 100644 libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 66c9dcba03..980443621c 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -245,6 +245,20 @@ fun TextComposer( canSendTextMessage, ) { when { + composerMode.isEditing -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, + endButtonClick = { + if (canSendTextMessage) { + onSendMessage() + } + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = canSendTextMessage, + isEditing = true, + ) + }, + ) !canSendTextMessage -> when (voiceMessageState) { VoiceMessageState.Idle -> EndButtonParams( @@ -296,18 +310,6 @@ fun TextComposer( ) } } - composerMode.isEditing -> EndButtonParams( - endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, - endButtonClick = { - onSendMessage() - }, - endButtonContent = @Composable { - SendButtonIcon( - canSendMessage = true, - isEditing = true, - ) - }, - ) else -> EndButtonParams( endButtonContentDescriptionResId = CommonStrings.action_send_message, endButtonClick = { diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt new file mode 100644 index 0000000000..c979e8c909 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * Copyright 2026 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.textcomposer + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.wysiwyg.display.TextDisplay +import org.junit.Test + +class TextComposerEndButtonTest : RobolectricTest() { + @Test + fun `editing a message with text shows the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "Some text", composerMode = aMessageComposerModeEdit()) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + } + + @Test + fun `emptying the composer while editing keeps the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = aMessageComposerModeEdit()) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist() + } + + @Test + fun `emptying the composer while editing a caption keeps the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = aMessageComposerModeEditCaption(content = "An existing caption")) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist() + } + + @Test + fun `an empty composer outside of edition still offers to record a voice message`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = MessageComposerMode.Normal) + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertIsDisplayed() + } + + private fun AndroidComposeUiTest.getString(resId: Int): String { + return activity!!.getString(resId) + } + + private fun AndroidComposeUiTest.setTextComposer( + text: String, + composerMode: MessageComposerMode, + ) { + setContent { + TextComposer( + state = aTextEditorStateMarkdown(initialText = text, initialFocus = true), + voiceMessageState = VoiceMessageState.Idle, + composerMode = composerMode, + onRequestFocus = {}, + onSendMessage = {}, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onError = {}, + onTyping = {}, + onReceiveSuggestion = {}, + onSelectRichContent = null, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + ) + } + } +} diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png index 96215547bb..bae0e74d1e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1622f9765fda65985a369d6958f7a5993696dd629c0c1013c0a1647cfb7b562 -size 50109 +oid sha256:25fdfa4b65263c02e10c6ddea16a3210a84722a742a66cd2d91b8b3f61968466 +size 49924 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png index 70d41498a8..389ae383c8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a94de003f65d2fb30ab42e4ac60802707abf1358b0defed7598a3aad69992f40 -size 48136 +oid sha256:dc99c7505aa48b7ccdbae1de6daec75c12b9293172c804ef3cf027f6b46c45ea +size 47883 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png index c17cbfdb66..45f5ce2bde 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdab4bea33337547250355c29991f45c118e8c2c05abdf69b1824475feced5d6 -size 51241 +oid sha256:c446e3b322a1eb8634f2f643819672cfb626ca9e76c7e1fb5a10fc03a1ae7824 +size 51031 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png index ef9c49d6c5..ff238fd979 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f92c594febc463911c8418deeff3b94e5a8ce4171461d812a0f4f527ab0cf8e -size 49245 +oid sha256:86260ffd841034c31327df736e0e127271f875a6cf383c688b7ea8c3a9d1dbd4 +size 48943 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png index 9ba3d99527..cebea0c88b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81d8acdb1158c3e5c19b5a602e3b9afe7643f1bb77d43f51d3f5c4327478a76a -size 50282 +oid sha256:9669ba8ab19e7f7f1322e973e6f39855113b45ab5f6b26e7b5230a2fe9ec1d68 +size 50067 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png index 969e5b04bf..50cd50736a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c225ad12c5a32e2392606f9afacc6dbc37478a141d5485e4942012c6b22ad052 -size 48046 +oid sha256:f1f66076e3b06df1471f367bed57f81d15272e3600eebc51cd131794e29d5dfb +size 47767 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png index a0192f0890..c4b21b86af 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cfb1d8b7828692d5c318206770f9484822a1749b3e80a618b502811cf512d76 -size 60489 +oid sha256:ffe11074abe52ccd1c34f687f780c5185be370b2b92867ee0b686bad85f28890 +size 60210 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png index c745fa256a..839c4a88ba 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca67409e4af247b43d8594d6351419cd12894485357596aff7b26745e2df009a -size 57687 +oid sha256:e9914f3ffd70dd83013ff6059539bda348ae64fc39a2419d621f8d198f6471cc +size 57380 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png index 635e14be9e..f47016a59f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b3a91d104a5949dde83ac38b3127afa342816c631a1e29d78e2ada1bcc906bb -size 50150 +oid sha256:895ba0487ed933f4c8eddc99fe008032fe01454771ccf2987905aec357f3d9c0 +size 49972 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png index 573b6123ed..adfcb6a616 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69c281829fbca15261f196d2134f933b789ff444cd7bebc88d05cdbb7553357f -size 48012 +oid sha256:b5a435c5aa78ffda52d96d611ab869d006e6c95e8aaf4cd005d99128b4f60b1c +size 47736 From b366515d1155122a775db0d902891dc642f71f08 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:59:00 +0200 Subject: [PATCH 048/118] Update dependency androidx.compose:compose-bom to v2026.08.00 (#7483) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a314a48c33..cc6e3cdc56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ camera = "1.6.1" work = "2.11.2" # Compose -compose_bom = "2026.06.01" +compose_bom = "2026.08.00" # Coroutines coroutines = "1.11.0" From 48f26ae33eaa8ff8340e377a50bf77455c55b294 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:31:25 +0300 Subject: [PATCH 049/118] Do not open the room again when the app is resumed from the recents list (#7436) MainActivity is a singleTask activity that declares the elementx://open deep link, so tapping a notification makes that deep link the base intent of the task. When the user later brings the app back from the recents list, the system replays that base intent with FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY, the resolver turns it into a navigation again and the user is pushed back into the room they had already left. Ignore any intent the system replayed from the history, the same way the launcher intent is already ignored. --- .../android/appnav/intent/IntentResolver.kt | 1 + .../appnav/intent/IntentResolverTest.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index ee316f00aa..5d3a85fa9a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -76,6 +76,7 @@ class IntentResolver( } private fun Intent.canBeIgnored(): Boolean { + if (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return true return action == Intent.ACTION_MAIN && categories?.contains(Intent.CATEGORY_LAUNCHER) == true } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 841f79678b..ddeae41a78 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -45,6 +45,24 @@ class IntentResolverTest : RobolectricTest() { assertThat(result).isNull() } + @Test + fun `resolve intent replayed from the history should return null`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = null, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + @Test fun `test resolve navigation intent root`() { val sut = createIntentResolver( From 918015b8bbff058b1d7bfa1da2abf12aa429d3b1 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:34:15 +0300 Subject: [PATCH 050/118] Stop waiting for a room alias that the remote server never resolves (#7446) Tapping a room alias whose server is down left the resolver screen on a spinner for as long as the screen stayed open, with no way forward other than going back. Bound the resolution with the ten second timeout already used when joining a room by address, so the retry dialog is shown instead. The fake client's resolve lambda becomes suspending so a server that never answers can be simulated. --- .../impl/RoomAliasResolverPresenter.kt | 10 +++++++++- .../impl/RoomAliasHelperPresenterTest.kt | 18 ++++++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt index 78be168e53..1ec0ce0691 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt @@ -24,7 +24,10 @@ import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeoutException import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration.Companion.seconds @AssistedInject class RoomAliasResolverPresenter( @@ -61,9 +64,14 @@ class RoomAliasResolverPresenter( private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { suspend { - matrixClient.resolveRoomAlias(roomAlias) + val result = withTimeoutOrNull(ALIAS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) { + matrixClient.resolveRoomAlias(roomAlias) + } ?: throw TimeoutException("Timed out while resolving ${roomAlias.value}") + result .getOrThrow() .getOrElse { throw RoomAliasResolverFailures.UnknownAlias } }.runCatchingUpdatingState(resolveState) } } + +private const val ALIAS_RESOLVE_TIMEOUT_IN_SECONDS = 10 diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt index df8b8058be..a50049446b 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt @@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SERVER_LIST import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import java.util.Optional +import java.util.concurrent.TimeoutException class RoomAliasHelperPresenterTest { @get:Rule @@ -60,6 +62,22 @@ class RoomAliasHelperPresenterTest { } } + @Test + fun `present - a server that never answers ends up in an error state`() = runTest { + val client = FakeMatrixClient( + resolveRoomAliasResult = { awaitCancellation() } + ) + val presenter = createPresenter(matrixClient = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().resolveState.isUninitialized()).isTrue() + assertThat(awaitItem().resolveState.isLoading()).isTrue() + val resultState = awaitItem() + assertThat(resultState.resolveState.errorOrNull()).isInstanceOf(TimeoutException::class.java) + } + } + @Test fun `present - resolve alias error and retry`() = runTest { val client = FakeMatrixClient( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 201f8cbf28..6ba42270fe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -100,7 +100,7 @@ class FakeMatrixClient( override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, - private val resolveRoomAliasResult: (RoomAlias) -> Result> = { + private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) ) From b774e6a280c142553cb29058a0491c40ccc1ace9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 10:12:25 +0200 Subject: [PATCH 051/118] Setting version for the release 26.08.2 --- plugins/src/main/kotlin/Versions.kt | 2 +- tools/manifest/gplay/release/aaptDump.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 1c77bea7cf..89ad4a63fc 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 8 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 1 +private const val versionReleaseNumber = 2 object Versions { /** diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index fcb3669a59..7eae1a5a78 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202608010' versionName='26.08.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202608020' versionName='26.08.2' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' From 9c867c9fc474865cb40538473462cd9e21e791f0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 10:12:51 +0200 Subject: [PATCH 052/118] Adding fastlane file for version 26.08.2 --- fastlane/metadata/android/en-US/changelogs/202608020.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202608020.txt diff --git a/fastlane/metadata/android/en-US/changelogs/202608020.txt b/fastlane/metadata/android/en-US/changelogs/202608020.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202608020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file From 7ba29d8a5e24ae6311489106273d9fc78d63cb07 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 10:40:43 +0200 Subject: [PATCH 053/118] Changelog for version 26.08.2 --- CHANGES.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 2955fc8ad7..db18e59251 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,86 @@ +Changes in Element X v26.08.2 +============================= + + + +**Note: this release in an internal release, it will not be pushed to the stores.** + +## What's Changed +### ✨ Features +* Remove the legacy web-based account creation for matrix.org by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7412 +### 🙌 Improvements +* Move the custom tile server URL config to the Element wellknown content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7416 +* Explain a rate limiting push gateway in the notification troubleshooter by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7448 +* Move the `:libraries:wellknown` logic into `enterprise` modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7429 +* Refresh the room members when a mention is started by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7438 +* Close the invite screen once the invite has been declined by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7440 +* Use Profiles sliding sync extension check for own profile subscription by @ganfra in https://github.com/element-hq/element-x-android/pull/7403 +* Simplify choosing the account provider during sign in, with autocomplete of previously used ones by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7394 +### 🐛 Bugfixes +* Fix wrong TextComposer placeholder displayed when in a thread. by @bmarty in https://github.com/element-hq/element-x-android/pull/7413 +* Fix bug reports failing to send after the app has crashed by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7395 +* Fix outgoing session verification stuck with incomplete recovery setup by @hardcore-sushi in https://github.com/element-hq/element-x-android/pull/7433 +* Keep transparency when an image is shared into the app with an imprecise mime type by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7452 +* Don't restart the sync service if the client is shutting down by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7472 +* Normalize the applicationName used in user agent by @bmarty in https://github.com/element-hq/element-x-android/pull/7477 +* Keep playing a voice message when another app plays a short sound by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7454 +* Keep the send button when the composer is emptied while editing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7456 +* Do not open the room again when the app is resumed from the recents list by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7436 +* Stop waiting for a room alias that the remote server never resolves by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7446 +### 🧱 Build +* build-rust-sdk: print the command we run by @richvdh in https://github.com/element-hq/element-x-android/pull/7407 +* Remove license check on Pro modules. by @bmarty in https://github.com/element-hq/element-x-android/pull/7418 +* Improve build time config and update the Firebase configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/7473 +* Make the PUSHER_APP_ID configurable at build time. by @bmarty in https://github.com/element-hq/element-x-android/pull/7478 +### 📄 Documentation +* Add KDoc to all the interfaces of the api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/7426 +### 🚧 In development 🚧 +* [LinkNewDevice] Ensure the notice screen is also protected by the timeout. by @bmarty in https://github.com/element-hq/element-x-android/pull/7428 +### Dependency upgrades +* Update kotlin to v2.3.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7396 +* Update plugin sonarqube to v7.4.0.8496 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7415 +* Update metro to v1.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7421 +* Update roborazzi to v1.71.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7423 +* Update dependency io.sentry:sentry-android to v8.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7414 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7420 +* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7431 +* Update dependency org.maplibre.compose:maplibre-compose to v0.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7432 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7460 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7479 +* Update media3 to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7427 +* Update dependency androidx.compose:compose-bom to v2026.08.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7483 +### 💼 Element Pro +* Add hooks to allow providing additional feature flags in EX pro by @richvdh in https://github.com/element-hq/element-x-android/pull/7419 +* Use the right base URL for the ESS config/well known endpoints by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7469 +### Others +* Do not display pinned events that the client cannot render by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7379 +* Hide the forward action on live location shares by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7343 +* Do not fail the screenshot recording job when there is nothing to commit by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7355 +* Show the account a UnifiedPush registration belongs to in the distributor by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7345 +* Stop repeating messages that are already shown in a room notification by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7370 +* Turn a matrix: URI in a message into a pill by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7354 +* Send a message with Enter on a physical keyboard, Shift+Enter for a new line by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7353 +* Do not group timeline messages sent more than five minutes apart by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7349 +* Linkify URLs whose host is a bracketed IPv6 address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7347 +* Add missing tests for `RustTemporaryMatrixClient` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7417 +* Revert "Keep manually revealed media visible when re-entering a room" by @bmarty in https://github.com/element-hq/element-x-android/pull/7424 +* Fix outdated documentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/7430 +* Sync strings - New language supported: Arabic by @bmarty in https://github.com/element-hq/element-x-android/pull/7461 +* Keep the focused login field visible above the keyboard by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7455 +* Relax image compression so photos and screenshots keep more detail by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7451 +* Open the user profile of a user id that contains a slash by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7445 +* Remove the F-Droid dex patching step from the release script by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7442 +* Convert markdown to HTML when a caption is edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7441 +* Exclude carriage returns from the login and password fields by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7439 +* Show an error when the room to leave cannot be found by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7437 +* Give the room name in the timeline top bar a 48dp tap target by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7450 +* Model the fake voice recorder on a cancellable recording job by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7466 + +## New Contributors +* @hardcore-sushi made their first contribution in https://github.com/element-hq/element-x-android/pull/7433 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.1...v26.08.2 + Changes in Element X v26.08.1 ============================= From 2525019ca1974bb44308cb09015327305506d82b Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:16:13 +0300 Subject: [PATCH 054/118] Show the right image when opening one that has not finished sending (#7359) * Show the right image when opening one that has not finished sending * Reuse the timeline media gallery for events that have no event id --- .../features/messages/impl/MessagesView.kt | 4 +-- .../messages/impl/MessagesViewTest.kt | 27 +++++++++++++++++++ .../impl/viewer/MediaViewerNode.kt | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5911de7372..ad71ada70d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -178,8 +178,8 @@ fun MessagesView( fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") - val eventId = event.eventId ?: return - if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return + val eventId = event.eventId + if (eventId != null && eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) if (hideKeyboard) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 2800c737ad..1350f1dfa0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -176,6 +176,33 @@ class MessagesViewTest : RobolectricTest() { callback.assertSuccess() } + @Test + fun `clicking on an Event which has not been sent yet invoke expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + val localEcho = aTimelineItemEvent( + isMine = true, + content = aTimelineItemTextContent(), + ).copy(eventId = null) + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = persistentListOf(localEcho), + ), + eventSink = eventsRecorder + ) + val callback = EnsureCalledOnceWithTwoParamsAndResult( + expectedParam1 = true, + expectedParam2 = localEcho, + result = true, + ) + setMessagesView( + state = state, + onEventClick = callback, + ) + // Cannot perform click on "Text", it's not detected. Use tag instead + onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() + callback.assertSuccess() + } + @Test fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() { `long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index e53153ce19..77222d97ff 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -92,7 +92,7 @@ class MediaViewerNode( is MediaViewerEntryPoint.Params.RoomMedia -> { val eventId = inputs.eventId if (eventId == null) { - // Should not happen + // Local echo, the viewer will not be focused on the item timelineMediaGalleryDataSource } else { // Can we use a specific timeline? From 4fa9576029a1631ea6aa4cc582a5494f59ddd85b Mon Sep 17 00:00:00 2001 From: ElementBot <110224175+ElementBot@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:44:15 +0100 Subject: [PATCH 055/118] Update Gradle Wrapper from 9.7.0 to 9.7.1 (#7485) Signed-off-by: gradle-update-robot Co-authored-by: gradle-update-robot --- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 84c5a9c661..760c45969c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 From 85226228e9453f1bee96298a27572e298f2c0450 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:48:50 +0200 Subject: [PATCH 056/118] Update dependency org.maplibre.gl:android-sdk to v13.5.0 (#7486) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc6e3cdc56..a170bf90d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -215,7 +215,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:13.4.1" +maplibre = "org.maplibre.gl:android-sdk:13.5.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_compose = "org.maplibre.compose:maplibre-compose:0.14.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" From e98d37cbcb4d24498e17e3c4326f8544f862a700 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:49:07 +0200 Subject: [PATCH 057/118] Update dependency androidx.webkit:webkit to v1.17.0 (#7484) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a170bf90d0..06846d0388 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -123,7 +123,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = "androidx.startup:startup-runtime:1.2.0" androidx_preference = "androidx.preference:preference:1.2.1" -androidx_webkit = "androidx.webkit:webkit:1.16.0" +androidx_webkit = "androidx.webkit:webkit:1.17.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' } From ca86711ecb7738b761a9b33afde1fa7b2eb434d6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:49:21 +0200 Subject: [PATCH 058/118] Update dependency io.sentry:sentry-android to v8.53.0 (#7481) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06846d0388..1e1751f3bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -230,7 +230,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.56.4" -sentry = "io.sentry:sentry-android:8.52.0" +sentry = "io.sentry:sentry-android:8.53.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" From f2883ef9df1aeb5b22130a82659a3d09a085160d Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 20 Aug 2026 11:49:59 +0200 Subject: [PATCH 059/118] Fix flaky test in `DefaultVoiceMessageComposerPresenterTest` (#7487) --- .../composer/DefaultVoiceMessageComposerPresenterTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 88d8ac38f1..fb44bb4e80 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -47,7 +47,6 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -153,7 +152,10 @@ class DefaultVoiceMessageComposerPresenterTest { ) presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) - val finalState = consumeItemsUntilTimeout().last() + + // Skip until we reach the final state, which should have the last 128 levels + skipItems(numberOfLevels - 1) + val finalState = awaitItem() assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java) val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording // The number of levels should be limited to 128 items From a078851b78c885467b555940a9c07f8eebe8f03f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 13:23:21 +0200 Subject: [PATCH 060/118] Add small script to force stop an application. --- tools/adb/force_stop.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100755 tools/adb/force_stop.sh diff --git a/tools/adb/force_stop.sh b/tools/adb/force_stop.sh new file mode 100755 index 0000000000..aaf9274010 --- /dev/null +++ b/tools/adb/force_stop.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# 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_name="io.element.android.x.debug" + +echo " => Force stop ${package_name}" + +adb shell am force-stop "${package_name}" From 9d36c8c4ed021e3da6f2af0f7dbfabff81d75d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 13:53:49 +0200 Subject: [PATCH 061/118] Export missing `MessageSearchService` to `SessionScope` --- .../android/libraries/matrix/impl/di/SessionMatrixModule.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 569714181b..f6ea8019e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.verification.SessionVerificationService @@ -114,4 +115,9 @@ object SessionMatrixModule { fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher { return matrixClient } + + @Provides + fun providesMessageSearchService(matrixClient: MatrixClient): MessageSearchService { + return matrixClient.messageSearchService + } } From be2909bca2b6538a84da3ba6c98a30e900863d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 13:55:15 +0200 Subject: [PATCH 062/118] Add a global search screen, which reads from both the `RoomListSearchDataSource` for the rooms search and `MessageSearchService` for the message search Note the designs and texts of this screen are temporary. --- .../home/impl/search/GlobalSearchEvent.kt | 18 + .../home/impl/search/GlobalSearchPresenter.kt | 283 +++++++++ .../home/impl/search/GlobalSearchState.kt | 92 +++ .../impl/search/GlobalSearchStateProvider.kt | 242 ++++++++ .../home/impl/search/GlobalSearchView.kt | 564 ++++++++++++++++++ .../impl/src/main/res/values/temporary.xml | 17 + .../components/avatar/AvatarData.kt | 2 + .../components/avatar/AvatarSize.kt | 3 + 8 files changed, 1221 insertions(+) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt create mode 100644 features/home/impl/src/main/res/values/temporary.xml diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt new file mode 100644 index 0000000000..76d9147e94 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt @@ -0,0 +1,18 @@ +/* + * 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.search + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface GlobalSearchEvent { + object ClearQuery : GlobalSearchEvent + object ToggleSearchVisibility : GlobalSearchEvent + data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent + data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt new file mode 100644 index 0000000000..11de6e7c80 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -0,0 +1,283 @@ +/* + * 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.search + +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.search.MessageSearch +import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.toPlainText +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration.Companion.milliseconds + +@AssistedInject +class GlobalSearchPresenter( + @Assisted private val coroutineScope: CoroutineScope, + private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, + private val messageSearchService: MessageSearchService, + private val featureFlagService: FeatureFlagService, + private val latestEventFormatter: RoomLatestEventFormatter, + private val dateFormatter: DateFormatter, + private val fileSizeFormatter: FileSizeFormatter, + private val permalinkParser: PermalinkParser, + private val coroutineDispatchers: CoroutineDispatchers, + private val matrixClient: MatrixClient, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(coroutineScope: CoroutineScope): GlobalSearchPresenter + } + + @Composable + override fun present(): GlobalSearchState { + val isEnabled by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } + } + val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) } + val queryState = rememberTextFieldState() + var isSearchActive by remember { mutableStateOf(false) } + var searchResults: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) } + var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } + val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } + + LaunchedEffect(queryState.text) { + // Add a delay to avoid performing too many searches in a short period of time, which can be expensive + delay(200.milliseconds) + + searchResults = if (queryState.text.isNotEmpty()) { + AsyncData.Loading(prevData = searchResults.dataOrNull()) + } else { + AsyncData.Uninitialized + } + + roomListSearchDataSource.setSearchQuery(queryState.text.toString()) + currentMessageSearch.setQuery(queryState.text.toString()) + } + + LaunchedEffect(currentTarget) { + if (queryState.text.isNotEmpty()) { + searchResults = AsyncData.Loading() + } + + when (currentTarget) { + GlobalSearchTarget.ROOMS -> { + roomListSearchDataSource.roomSummaries.collectLatest { results -> + // The room list data source always returns some values, even if the query is empty, + // so we only want to update the search results if the query is not empty + searchResults = if (queryState.text.isNotEmpty() && results.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } else { + AsyncData.Uninitialized + } + } + } + GlobalSearchTarget.MESSAGES -> { + currentMessageSearch.results.collectLatest { results -> + // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each + // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. + val mappedResults = results.map { result -> + async(coroutineDispatchers.computation) { + val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null } + val formattedTimestamp = dateFormatter.format( + timestamp = result.timestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true + ) + + if (!result.content.isMediaContent()) { + mapMessageContent(result, roomInfo, formattedTimestamp) + } else { + mapMediaContent(result, roomInfo, formattedTimestamp) + } + } + } + .awaitAll() + .mapNotNull { it } + + searchResults = if (queryState.text.isNotEmpty() && mappedResults.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) + } else { + AsyncData.Uninitialized + } + } + } + } + } + + fun handleEvent(event: GlobalSearchEvent) { + when (event) { + GlobalSearchEvent.ClearQuery -> { + queryState.clearText() + } + GlobalSearchEvent.ToggleSearchVisibility -> { + isSearchActive = !isSearchActive + queryState.clearText() + } + is GlobalSearchEvent.UpdateVisibleRange -> { + when (currentTarget) { + GlobalSearchTarget.ROOMS -> coroutineScope.launch { + // TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe + // to the room info for each room in the search results. + roomListSearchDataSource.updateVisibleRange(event.range) + } + GlobalSearchTarget.MESSAGES -> coroutineScope.launch { + val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0 + val currentPaginationState = currentMessageSearch.paginationState.value + val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached + if (event.range.last >= currentCount - 10 && canPaginate) { + currentMessageSearch.paginate() + } + } + } + } + is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target + } + } + + return GlobalSearchState( + isEnabled = isEnabled, + isSearchActive = isSearchActive, + queryState = queryState, + currentTarget = currentTarget, + results = searchResults, + eventSink = ::handleEvent, + ) + } + + private fun mapMessageContent( + result: MessageSearchResult, + roomInfo: RoomInfo, + formattedTimestamp: String, + ): MessageSearchResultItem.Message? { + val body = latestEventFormatter.format( + latestEvent = LatestEventValue.Remote( + timestamp = result.timestamp, + content = result.content, + senderId = result.senderId, + senderProfile = result.senderProfile, + isOwn = matrixClient.isMe(result.senderId), + ), + isDmRoom = false, + ) + return MessageSearchResultItem.Message( + messageSearchResult = result, + body = body?.toString() ?: "", + formattedTimestamp = formattedTimestamp, + roomInfo = roomInfo, + ) + } + + private fun mapMediaContent( + result: MessageSearchResult, + roomInfo: RoomInfo, + formattedTimestamp: String, + ): MessageSearchResultItem.Media? { + val messageType = when (val content = result.content) { + is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment + else -> return null + } + + val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename) + + val thumbnailType = when (messageType) { + is ImageMessageType -> AttachmentThumbnailType.Image + is VideoMessageType -> AttachmentThumbnailType.Video + is AudioMessageType -> AttachmentThumbnailType.Audio + is VoiceMessageType -> AttachmentThumbnailType.Voice + is FileMessageType -> AttachmentThumbnailType.File + is StickerMessageType -> AttachmentThumbnailType.Image + } + val thumbnailSource = when (messageType) { + is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source + is VideoMessageType -> messageType.info?.thumbnailSource + is AudioMessageType -> null + is VoiceMessageType -> null + is FileMessageType -> null + is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source + } + val blurhash = when (messageType) { + is ImageMessageType -> messageType.info?.blurhash + is VideoMessageType -> messageType.info?.blurhash + is AudioMessageType -> null + is VoiceMessageType -> null + is FileMessageType -> null + is StickerMessageType -> messageType.info?.blurhash + } + + val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase() + val formattedSize = when (messageType) { + is ImageMessageType -> messageType.info?.size + is VideoMessageType -> messageType.info?.size + is AudioMessageType -> messageType.info?.size + is VoiceMessageType -> messageType.info?.size + is FileMessageType -> messageType.info?.size + is StickerMessageType -> messageType.info?.size + } + ?.let(fileSizeFormatter::format) + + val mediaContent = MediaSearchResultContent( + filename = messageType.filename, + extension = extension, + caption = caption, + formattedSize = formattedSize, + thumbnailSource = thumbnailSource, + thumbnailType = thumbnailType, + blurhash = blurhash + ) + + return MessageSearchResultItem.Media( + messageSearchResult = result, + mediaContent = mediaContent, + formattedTimestamp = formattedTimestamp, + roomInfo = roomInfo, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt new file mode 100644 index 0000000000..d623fd5594 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt @@ -0,0 +1,92 @@ +/* + * 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.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.architecture.AsyncData +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.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class GlobalSearchState( + val isEnabled: Boolean, + val isSearchActive: Boolean, + val queryState: TextFieldState, + val currentTarget: GlobalSearchTarget, + val results: AsyncData, + val eventSink: (GlobalSearchEvent) -> Unit +) + +@Immutable +enum class GlobalSearchTarget { + ROOMS, + MESSAGES, +} + +@Immutable +sealed interface GlobalSearchResults { + data class RoomListResults(val results: ImmutableList) : GlobalSearchResults + data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults + + fun isEmpty(): Boolean = when (this) { + is RoomListResults -> results.isEmpty() + is MessageSearchResults -> results.isEmpty() + } +} + +sealed interface MessageSearchResultItem { + val roomId: RoomId + val eventId: EventId + val senderId: UserId + val senderName: String + val roomInfo: RoomInfo + val formattedTimestamp: String + + data class Message( + val messageSearchResult: MessageSearchResult, + val body: String, + override val roomInfo: RoomInfo, + override val formattedTimestamp: String, + ) : MessageSearchResultItem { + override val roomId: RoomId = messageSearchResult.roomId + override val eventId: EventId = messageSearchResult.eventId + override val senderId: UserId = messageSearchResult.senderId + override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) + } + + data class Media( + val messageSearchResult: MessageSearchResult, + val mediaContent: MediaSearchResultContent, + override val roomInfo: RoomInfo, + override val formattedTimestamp: String, + ) : MessageSearchResultItem { + override val roomId: RoomId = messageSearchResult.roomId + override val eventId: EventId = messageSearchResult.eventId + override val senderId: UserId = messageSearchResult.senderId + override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) + } +} + +data class MediaSearchResultContent( + val filename: String, + val extension: String?, + val caption: String?, + val formattedSize: String?, + val thumbnailSource: MediaSource?, + val thumbnailType: AttachmentThumbnailType, + val blurhash: String?, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt new file mode 100644 index 0000000000..c4343b8982 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt @@ -0,0 +1,242 @@ +/* + * 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.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.CallIntentConsensus +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList + +class GlobalSearchStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Not enabled, no preview + aGlobalSearchState(isEnabled = false), + aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Loading(), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.RoomListResults(aRoomListRoomSummaryList())), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( + MessageSearchResultItem.Message( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), + body = "A message", + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + MessageSearchResultItem.Media( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), + mediaContent = MediaSearchResultContent( + filename = "file.png", + extension = "PNG", + caption = "A caption", + formattedSize = "1 MB", + thumbnailSource = MediaSource("https://example.com/thumbnail.png"), + thumbnailType = AttachmentThumbnailType.Image, + blurhash = null, + ), + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + ))), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), + ), + ) +} + +fun aRoomInfo( + id: RoomId = RoomId("!roomId:server.org"), + name: String? = "A room", + rawName: String? = "A room raw name", + topic: String? = "A room topic", + avatarUrl: String? = "https://example.com/avatar.png", + isPublic: Boolean = true, + isDirect: Boolean = false, + isEncrypted: Boolean = false, + joinRule: JoinRule? = JoinRule.Public, + isSpace: Boolean = false, + successorRoom: SuccessorRoom? = null, + isFavorite: Boolean = false, + canonicalAlias: RoomAlias? = null, + alternativeAliases: List = emptyList(), + currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, + inviter: RoomMember? = null, + activeMembersCount: Long = 2, + invitedMembersCount: Long = 1, + joinedMembersCount: Long = 1, + highlightCount: Long = 0, + notificationCount: Long = 0, + userDefinedNotificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + roomPowerLevels: RoomPowerLevels? = RoomPowerLevels( + values = RoomPowerLevelsValues( + ban = 0, + invite = 0, + kick = 0, + eventsDefault = 0, + stateDefault = 0, + redactEvents = 0, + roomName = 0, + roomAvatar = 0, + roomTopic = 0, + spaceChild = 0, + beacon = 0, + beaconInfo = 0 + ), + users = persistentMapOf(), + ), + activeRoomCallParticipants: List = emptyList(), + heroes: List = emptyList(), + pinnedEventIds: List = emptyList(), + roomCreators: List = emptyList(), + isMarkedUnread: Boolean = false, + numUnreadMessages: Long = 0, + numUnreadNotifications: Long = 0, + numUnreadMentions: Long = 0, + historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, + roomVersion: String? = "11", + privilegedCreatorRole: Boolean = false, + isLowPriority: Boolean = false, + activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None, + isDm: Boolean = false, + fullyReadEventId: EventId? = null, +) = RoomInfo( + id = id, + name = name, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + isPublic = isPublic, + isDirect = isDirect, + isEncrypted = isEncrypted, + joinRule = joinRule, + isSpace = isSpace, + successorRoom = successorRoom, + isFavorite = isFavorite, + canonicalAlias = canonicalAlias, + alternativeAliases = alternativeAliases.toImmutableList(), + currentUserMembership = currentUserMembership, + inviter = inviter, + activeMembersCount = activeMembersCount, + invitedMembersCount = invitedMembersCount, + joinedMembersCount = joinedMembersCount, + highlightCount = highlightCount, + notificationCount = notificationCount, + userDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = hasRoomCall, + roomPowerLevels = roomPowerLevels, + activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), + heroes = heroes.toImmutableList(), + pinnedEventIds = pinnedEventIds.toImmutableList(), + creators = roomCreators.toImmutableList(), + isMarkedUnread = isMarkedUnread, + numUnreadMessages = numUnreadMessages, + numUnreadNotifications = numUnreadNotifications, + numUnreadMentions = numUnreadMentions, + historyVisibility = historyVisibility, + roomVersion = roomVersion, + privilegedCreatorRole = privilegedCreatorRole, + isLowPriority = isLowPriority, + activeCallIntentConsensus = activeCallIntentConsensus, + isDm = isDm, + fullyReadEventId = fullyReadEventId, +) + +fun aMessageContent( + body: String = "body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + threadInfo: EventThreadInfo? = null, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + threadInfo = threadInfo, + type = messageType +) + +internal fun aMessageSearchResult( + roomId: RoomId = RoomId("!roomId:server.org"), + eventId: EventId = EventId("\$eventId:server.org"), + senderId: UserId = UserId("@user:server.org"), + senderProfile: ProfileDetails = aProfileDetailsReady(), + content: MessageContent = aMessageContent(), + timestamp: Long = 0L, +) = MessageSearchResult( + roomId = roomId, + eventId = eventId, + senderId = senderId, + senderProfile = senderProfile, + content = content, + timestamp = timestamp, +) + +internal fun aGlobalSearchState( + isEnabled: Boolean = true, + isSearchActive: Boolean = false, + queryState: TextFieldState = TextFieldState(), + currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, + results: AsyncData = AsyncData.Uninitialized, + eventSink: (GlobalSearchEvent) -> Unit = {}, +) = GlobalSearchState( + isEnabled = isEnabled, + isSearchActive = isSearchActive, + queryState = queryState, + currentTarget = currentTarget, + results = results, + eventSink = eventSink +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt new file mode 100644 index 0000000000..593a5ed6bf --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt @@ -0,0 +1,564 @@ +/* + * 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.search + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.theme.components.FilledTextField +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding +import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun GlobalSearchView( + state: GlobalSearchState, + onSelectSearchResult: (RoomId, EventId?) -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = state.isSearchActive) { + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + + AnimatedVisibility( + visible = state.isSearchActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column(modifier = modifier) { + GlobalSearchContent( + state = state, + onBackButtonClick = { + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + }, + onSelectSearchResult = onSelectSearchResult, + ) + } + } +} + +@Composable +private fun GlobalSearchContent( + state: GlobalSearchState, + onBackButtonClick: () -> Unit, + onSelectSearchResult: (RoomId, EventId?) -> Unit, +) { + val borderColor = MaterialTheme.colorScheme.tertiary + val strokeWidth = 1.dp + + val drawBottomLineModifier = Modifier.drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth.value + ) + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { BackButton(onClick = onBackButtonClick) }, + title = { + // The stateSaver will keep the selection state when returning to this UI + val focusRequester = remember { FocusRequester() } + val searchLabel = stringResource(CommonStrings.action_search) + FilledTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .semantics { contentDescription = searchLabel }, + state = state.queryState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + trailingIcon = if (state.queryState.text.isNotEmpty()) { + @Composable { + IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.a11y_clear_search_field) + ) + } + } + } else { + null + }, + ) + + LaunchedEffect(Unit) { + if (!focusRequester.restoreFocusedChild()) { + focusRequester.requestFocus() + } + focusRequester.saveFocusedChild() + } + } + ) + }, + contentWindowInsets = scaffoldScrollableContentInsets, + ) { padding -> + Column( + modifier = Modifier.padding(padding), + ) { + SingleChoiceSegmentedButtonRow( + modifier = drawBottomLineModifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) + ) { + SegmentedButton( + index = 0, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.ROOMS, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) }, + text = stringResource(R.string.search_section_chats), + ) + + SegmentedButton( + index = 1, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.MESSAGES, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) }, + text = stringResource(R.string.search_section_messages), + ) + } + + val lazyListState = rememberLazyListState() + OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> + state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange)) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = lazyColumnContentPadding, + state = lazyListState, + ) { + val results = state.results.dataOrNull() + when { + state.results.isUninitialized() -> startSearching() + state.results.isLoading() -> loading() + results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString()) + results is GlobalSearchResults.RoomListResults -> roomListResults( + results = results.results, + onRoomClick = { roomId -> onSelectSearchResult(roomId, null) }, + ) + results is GlobalSearchResults.MessageSearchResults -> messageSearchResults( + results = results.results, + onSearchResultSelected = onSelectSearchResult, + ) + } + } + } + } +} + +private fun LazyListScope.startSearching() { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + title = stringResource(R.string.start_searching_title), + subTitle = stringResource(R.string.start_searching_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), + ) + } +} + +private fun LazyListScope.loading() { + item { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + +private fun LazyListScope.emptySearchResults(query: String) { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + title = stringResource(R.string.search_no_results_title), + subTitle = stringResource(R.string.search_no_results_subtitle, query), + iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), + ) + } +} + +private fun LazyListScope.roomListResults( + results: ImmutableList, + onRoomClick: (RoomId) -> Unit, +) { + itemsIndexed( + items = results, + key = { _, roomSummary -> roomSummary.roomId }, + ) { index, result -> + Row( + modifier = Modifier + .niceClickable(onClick = { onRoomClick(result.roomId) }) + .fillMaxWidth() + .addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem), + avatarType = AvatarType.Room( + heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(), + isTombstoned = result.isTombstoned, + ), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = result.name ?: result.roomId.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + + if (result.isDm) { + val userId = result.heroes.firstOrNull()?.id + userId?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } else { + result.canonicalAlias?.let { + Text( + text = it.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } +} + +private fun LazyListScope.messageSearchResults( + results: ImmutableList, + onSearchResultSelected: (RoomId, EventId?) -> Unit, +) { + if (results.isNotEmpty()) { + item { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + text = stringResource(R.string.search_from_your_messages_header), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + itemsIndexed( + items = results, + contentType = { _, messageSearchResult -> messageSearchResult::class.java }, + key = { _, messageSearchResult -> messageSearchResult.eventId }, + ) { index, result -> + val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) + when (result) { + is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( + modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), + avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), + avatarType = AvatarType.Room( + heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(), + isTombstoned = result.roomInfo.successorRoom != null + ), + roomName = result.roomInfo.name ?: result.roomInfo.id.value, + body = result.body, + formattedTimestamp = result.formattedTimestamp, + ) + is MessageSearchResultItem.Media -> { + MediaMessageSearchResultItemView( + modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), + avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), + avatarType = AvatarType.Room( + heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(), + isTombstoned = result.roomInfo.successorRoom != null + ), + roomName = result.roomInfo.name ?: result.roomInfo.id.value, + content = result.mediaContent, + formattedTimestamp = result.formattedTimestamp, + ) + } + } + } +} + +@Composable +private fun TextMessageSearchResultItemView( + avatarData: AvatarData, + avatarType: AvatarType, + roomName: String, + body: String, + formattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + modifier = Modifier.weight(1f), + text = roomName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = formattedTimestamp, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Text( + text = body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun MediaMessageSearchResultItemView( + avatarData: AvatarData, + avatarType: AvatarType, + roomName: String, + content: MediaSearchResultContent, + formattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + modifier = Modifier.weight(1f), + text = roomName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = formattedTimestamp, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + + content.caption?.let { + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Row( + modifier = Modifier + .fillMaxWidth(1f) + .background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AttachmentThumbnail( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)), + info = AttachmentThumbnailInfo( + type = content.thumbnailType, + thumbnailSource = content.thumbnailSource, + textContent = null, + blurHash = content.blurhash, + ), + thumbnailSize = 36.dp.roundToPx().toLong(), + backgroundColor = ElementTheme.colors.bgCanvasDefault, + ) + + Column { + Text( + text = content.filename, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + content.extension?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + content.formattedSize?.let { + Text( + text = "($it)", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } + } +} + +private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier { + if (isLastItem) return this + + return this.drawBehind { + val strokeWidthPx = 1.dp.toPx() + drawLine( + color = color, + start = Offset(0f, size.height - strokeWidthPx), + end = Offset(size.width, size.height - strokeWidthPx), + strokeWidth = strokeWidthPx, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun GlobalSearchViewPreview( + @PreviewParameter(GlobalSearchStateProvider::class) state: GlobalSearchState, +) { + ElementPreview { + GlobalSearchView( + state = state, + onSelectSearchResult = { _, _ -> }, + ) + } +} diff --git a/features/home/impl/src/main/res/values/temporary.xml b/features/home/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..c44001e5a6 --- /dev/null +++ b/features/home/impl/src/main/res/values/temporary.xml @@ -0,0 +1,17 @@ + + + + + Start searching… + Search for chats and messages + Chats + From your messages + Messages + No results + There are no results for “%1$s”. Try a new search term. + diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index ac7e426e65..99c45c67d9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.designsystem.components.avatar +import androidx.compose.runtime.Immutable import io.element.android.libraries.core.data.tryOrNull import java.text.BreakIterator +@Immutable data class AvatarData( val id: String, val name: String?, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index eb27780073..b91f8c405c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.designsystem.components.avatar +import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +@Immutable enum class AvatarSize(val dp: Dp) { CurrentUserTopBar(32.dp), CurrentRoomTopBar(32.dp), @@ -18,6 +20,7 @@ enum class AvatarSize(val dp: Dp) { IncomingCall(140.dp), RoomDetailsHeader(96.dp), RoomListItem(52.dp), + SearchRoomListItem(32.dp), ThreadsListItem(52.dp), SpaceListItem(52.dp), From 3ca5081f33b36d774fd7f60ec52f781f93378890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 13:56:29 +0200 Subject: [PATCH 063/118] Integrate the new screen with the existing home screen, display it only when the search feature flag is enabled --- .../android/appnav/LoggedInFlowNode.kt | 4 +- .../features/home/api/HomeEntryPoint.kt | 3 +- .../features/home/impl/HomeFlowNode.kt | 8 ++- .../android/features/home/impl/HomeView.kt | 57 +++++++++++++------ .../home/impl/roomlist/RoomListPresenter.kt | 8 +++ .../home/impl/roomlist/RoomListState.kt | 2 + .../impl/roomlist/RoomListStateProvider.kt | 4 ++ .../home/impl/DefaultHomeEntryPointTest.kt | 3 +- 8 files changed, 66 insertions(+), 23 deletions(-) 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 cd071131d9..dab9b7828c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -335,11 +335,11 @@ class LoggedInFlowNode( } NavTarget.Home -> { val callback = object : HomeEntryPoint.Callback { - override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) { + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) { lifecycleScope.launch { attachRoom( roomIdOrAlias = roomId.toRoomIdOrAlias(), - initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom), + initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom, eventId = eventId), clearBackstack = false, ) } 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 1a90245611..1c6a0a906e 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 @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +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.room.JoinedRoom @@ -23,7 +24,7 @@ interface HomeEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) + fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) fun navigateToCreateRoom() fun navigateToCreateSpace() fun navigateToSettings() 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 b7b05cca87..20f27f67bd 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 @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.utils.DelayedVisibility import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope @@ -183,6 +184,7 @@ class HomeFlowNode( fun navigateToRoom( roomId: RoomId, + eventId: EventId?, ) { if (!loadingJoinedRoomJob.value.isUninitialized()) { Timber.w("Already loading a room, ignoring navigateToRoom for $roomId") @@ -195,7 +197,7 @@ class HomeFlowNode( }.fold( onSuccess = { joinedRoom -> if (isActive) { - callback.navigateToRoom(roomId, joinedRoom) + callback.navigateToRoom(roomId = roomId, eventId = eventId, joinedRoom = joinedRoom) loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job) // Wait a bit before resetting the state to avoid allowing to open several rooms delay(200.milliseconds) @@ -205,7 +207,7 @@ class HomeFlowNode( onFailure = { // If the operation wasn't cancelled, navigate without the room, using the room id if (it !is CancellationException) { - callback.navigateToRoom(roomId, null) + callback.navigateToRoom(roomId = roomId, eventId = null, joinedRoom = null) } loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job) // Wait a bit before resetting the state to avoid allowing to open several rooms @@ -233,7 +235,7 @@ class HomeFlowNode( acceptDeclineInviteView = { acceptDeclineInviteView.Render( state = state.roomListState.acceptDeclineInviteState, - onAcceptInviteSuccess = ::navigateToRoom, + onAcceptInviteSuccess = { navigateToRoom(roomId = it, eventId = null) }, onDeclineInviteSuccess = { }, modifier = Modifier ) 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 f5a2400f2f..97b43b17cc 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 @@ -55,6 +55,8 @@ import io.element.android.features.home.impl.roomlist.RoomListContextMenu import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu import io.element.android.features.home.impl.roomlist.RoomListEvent import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchEvent +import io.element.android.features.home.impl.search.GlobalSearchView import io.element.android.features.home.impl.search.RoomListSearchView import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -73,6 +75,7 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets 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.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -80,7 +83,7 @@ import kotlinx.coroutines.launch @Composable fun HomeView( homeState: HomeState, - onRoomClick: (RoomId) -> Unit, + onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, @@ -122,22 +125,34 @@ fun HomeView( state = homeState, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, - onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, + onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) }, onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() }, onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, ) - // This overlaid view will only be visible when state.displaySearchResults is true - RoomListSearchView( - state = state.searchState, - eventSink = state.eventSink, - hideInvitesAvatars = state.hideInvitesAvatars, - onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, - modifier = Modifier - .fillMaxSize() - .background(ElementTheme.colors.bgCanvasDefault) - ) + + if (state.globalSearchState.isEnabled) { + GlobalSearchView( + state = state.globalSearchState, + onSelectSearchResult = { roomId, eventId -> if (firstThrottler.canHandle()) onRoomClick(roomId, eventId) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + ) + } else { + // This overlaid view will only be visible when state.displaySearchResults is true + RoomListSearchView( + state = state.searchState, + eventSink = state.eventSink, + hideInvitesAvatars = state.hideInvitesAvatars, + onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault) + ) + } + acceptDeclineInviteView() } } @@ -186,8 +201,18 @@ private fun HomeScaffold( selectedNavigationItem = state.currentHomeNavigationBarItem, currentUserAndNeighbors = state.currentUserAndNeighbors, showAvatarIndicator = state.showAvatarIndicator, - areSearchResultsDisplayed = roomListState.searchState.isSearchActive, - onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) }, + areSearchResultsDisplayed = if (roomListState.globalSearchState.isEnabled) { + roomListState.globalSearchState.isSearchActive + } else { + roomListState.searchState.isSearchActive + }, + onToggleSearch = { + if (roomListState.globalSearchState.isEnabled) { + roomListState.globalSearchState.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } else { + roomListState.eventSink(RoomListEvent.ToggleSearchResults) + } + }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, onAccountSwitch = { @@ -349,7 +374,7 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview { HomeView( homeState = state, - onRoomClick = {}, + onRoomClick = { _, _ -> }, onSettingsClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, @@ -369,7 +394,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: internal fun HomeViewA11yPreview() = ElementPreview { HomeView( homeState = aHomeState(), - onRoomClick = {}, + onRoomClick = { _, _ -> }, onSettingsClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, 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 fe09bd7cd1..47b02cac08 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 @@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -85,10 +86,15 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, + private val globalSearchPresenterFactory: GlobalSearchPresenter.Factory, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService + private val globalSearchPresenter by lazy { + globalSearchPresenterFactory.create(coroutineScope = client.sessionCoroutineScope) + } + @Composable override fun present(): RoomListState { val coroutineScope = rememberCoroutineScope() @@ -97,6 +103,7 @@ class RoomListPresenter( val searchState = searchPresenter.present() val spaceFiltersState = spaceFiltersPresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + val globalSearchState = globalSearchPresenter.present() LaunchedEffect(Unit) { roomListDataSource.launchIn(this) @@ -177,6 +184,7 @@ class RoomListPresenter( leaveRoomState = leaveRoomState, filtersState = filtersState, searchState = searchState, + globalSearchState = globalSearchState, spaceFiltersState = spaceFiltersState, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, 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 e184a5a57f..4164c56e9d 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 @@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist import androidx.compose.runtime.Immutable import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState @@ -27,6 +28,7 @@ data class RoomListState( val leaveRoomState: LeaveRoomState, val filtersState: RoomListFiltersState, val searchState: RoomListSearchState, + val globalSearchState: GlobalSearchState, val spaceFiltersState: SpaceFiltersState, val contentState: RoomListContentState, val acceptDeclineInviteState: AcceptDeclineInviteState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt index 97600e0247..034e7acd42 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -16,7 +16,9 @@ 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.model.aRoomListRoomSummary import io.element.android.features.home.impl.model.anInviteSender +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aGlobalSearchState import io.element.android.features.home.impl.search.aRoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState @@ -53,6 +55,7 @@ internal fun aRoomListState( declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden, leaveRoomState: LeaveRoomState = aLeaveRoomState(), searchState: RoomListSearchState = aRoomListSearchState(), + globalSearchState: GlobalSearchState = aGlobalSearchState(), filtersState: RoomListFiltersState = aRoomListFiltersState(), spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(), contentState: RoomListContentState = aRoomsContentState(), @@ -66,6 +69,7 @@ internal fun aRoomListState( leaveRoomState = leaveRoomState, filtersState = filtersState, searchState = searchState, + globalSearchState = globalSearchState, spaceFiltersState = spaceFiltersState, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, 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 5e8570adec..c0cb5f7c01 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 @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat 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.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -44,7 +45,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { ) } val callback = object : HomeEntryPoint.Callback { - override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError() + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) = lambdaError() override fun navigateToCreateRoom() = lambdaError() override fun navigateToCreateSpace() = lambdaError() override fun navigateToSettings() = lambdaError() From d471241da871d72e9c5c904d383161777ebe4dfb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 13:57:55 +0200 Subject: [PATCH 064/118] Fix account provisioning deep link being ignored when it cold starts the app At cold start two writers set the root nav target of RootFlowNode: the intent path (handleIntent -> onLoginLink -> NotLoggedInFlow(params)) and the first nav state emission (NotLoggedIn -> NotLoggedInFlow(null)). safeRoot() only dedupes on strict equality, so the two targets do not cancel each other out and the last writer wins, silently dropping the login params. Keep the params coming from the intent as pending state on the node, and let the nav state observer take them into account, so that the root is derived from both the logged in state and the pending login params, and both orderings converge on the same target. The pending params are cleared when leaving the not logged in flow, so that a later sign out does not resurrect a stale provisioning link. Also log when switching to the not logged in flow, since the drop was silent. The login hint can contain an email address, so only its presence is logged. Closes #7482 Co-Authored-By: Claude Opus 5 (1M context) --- appnav/build.gradle.kts | 3 + .../io/element/android/appnav/RootFlowNode.kt | 14 +- .../android/appnav/RootFlowNodeTest.kt | 267 ++++++++++++++++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 054e9525c7..64aa2a6fc5 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -63,11 +63,14 @@ dependencies { testImplementation(projects.features.share.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixmedia.test) testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.features.announcement.test) testImplementation(projects.features.forward.test) testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) 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 acf7b66db9..19ec427303 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -109,6 +109,13 @@ class RootFlowNode( buildContext = buildContext, plugins = plugins ) { + /** + * Login params coming from a launch or new [Intent], waiting to be consumed by the not logged in flow. + * Kept here so that the root nav target can be computed from both the logged in state and the pending + * login params, whatever the order in which the intent and the first nav state emission are processed. + */ + private var pendingLoginParams: LoginParams? = null + override fun onBuilt() { analyticsColdStartWatcher.start() appCoroutineScope.launch { @@ -152,7 +159,7 @@ class RootFlowNode( } } LoggedInState.NotLoggedIn -> { - switchToNotLoggedInFlow(null) + switchToNotLoggedInFlow(pendingLoginParams) } } } @@ -194,15 +201,18 @@ class RootFlowNode( } private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + pendingLoginParams = null backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) } private fun switchToNotLoggedInFlow(params: LoginParams?) { + Timber.d("switchToNotLoggedInFlow, hasLoginParams=${params != null}") matrixSessionCache.removeAll() backstack.safeRoot(NavTarget.NotLoggedInFlow(params)) } private fun switchToSignedOutFlow(sessionId: SessionId) { + pendingLoginParams = null backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) } @@ -319,6 +329,7 @@ class RootFlowNode( } override fun onDone() { + pendingLoginParams = null backstack.pop() } } @@ -418,6 +429,7 @@ class RootFlowNode( Timber.w("Login link ignored, multi account is disabled") } } else { + pendingLoginParams = params switchToNotLoggedInFlow(params) } } else { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt new file mode 100644 index 0000000000..e749ffa492 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -0,0 +1,267 @@ +/* + * 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(ExperimentalCoroutinesApi::class) + +package io.element.android.appnav + +import android.content.Intent +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.activeElement +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper +import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.di.SyncOrchestrator +import io.element.android.appnav.intent.IntentResolver +import io.element.android.appnav.root.RootNavStateFlowFactory +import io.element.android.appnav.root.RootPresenter +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.api.LoginParams +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.preferences.api.CacheService +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.share.test.FakeShareIntentHandler +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.deeplink.api.DeeplinkParser +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.FakeSdkMetadata +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.oauth.api.OAuthAction +import io.element.android.libraries.oauth.api.OAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher +import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.reflect.KClass + +class RootFlowNodeTest : RobolectricTest() { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest { + val loginEntryPoint = FakeLoginEntryPoint() + val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + rootFlowNode.parentNodeTestHelper() + // The intent is handled first, this is what happens when the deep link cold starts the app. + rootFlowNode.handleIntent(aLoginIntent()) + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + // Then the first nav state emission lands, it must not override the login params. + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + } + + @Test + fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest { + val loginEntryPoint = FakeLoginEntryPoint() + val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + rootFlowNode.parentNodeTestHelper() + // The nav state is observed first, this is what happens when the app is already running. + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(null)) + rootFlowNode.handleIntent(aLoginIntent()) + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + } + + private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK)) + + private fun TestScope.createRootFlowNode( + loginEntryPoint: LoginEntryPoint = FakeLoginEntryPoint(), + sessionStore: SessionStore = InMemorySessionStore(), + ): RootFlowNode { + val matrixSessionCache = MatrixSessionCache( + authenticationService = FakeMatrixAuthenticationService(), + syncOrchestratorFactory = FakeSyncOrchestratorFactory(), + analyticsService = FakeAnalyticsService(), + ) + val parentNode = FakeParentNode( + graph = FakeNodeFactoriesBindings( + mapOf( + NotLoggedInFlowNode::class to AssistedNodeFactory { buildContext, plugins -> + NotLoggedInFlowNode( + buildContext = buildContext, + plugins = plugins, + loginEntryPoint = loginEntryPoint, + imageLoaderHolder = FakeImageLoaderHolder(), + analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + ) + } + ) + ) + ) + return RootFlowNode( + buildContext = BuildContext( + ancestryInfo = AncestryInfo.Child(anchor = parentNode), + savedStateMap = null, + customisations = NodeCustomisationDirectoryImpl(), + ), + plugins = emptyList(), + sessionStore = sessionStore, + accountProviderAccessControl = FakeAccountProviderAccessControl(), + navStateFlowFactory = RootNavStateFlowFactory( + sessionStore = sessionStore, + cacheService = FakeCacheService(), + matrixSessionCache = matrixSessionCache, + imageLoaderHolder = FakeImageLoaderHolder(), + sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(), + ), + matrixSessionCache = matrixSessionCache, + presenter = RootPresenter( + crashDetectionPresenter = NotUsedPresenter(), + rageshakeDetectionPresenter = NotUsedPresenter(), + appErrorStateService = FakeAppErrorStateService(), + analyticsService = FakeAnalyticsService(), + sdkMetadata = FakeSdkMetadata("sha"), + ), + bugReportEntryPoint = FakeBugReportEntryPoint(), + signedOutEntryPoint = FakeSignedOutEntryPoint(), + accountSelectEntryPoint = FakeAccountSelectEntryPoint(), + intentResolver = IntentResolver( + deeplinkParser = DeeplinkParser { null }, + loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS }, + oAuthIntentResolver = FakeOAuthIntentResolver { null }, + permalinkParser = FakePermalinkParser(), + shareIntentHandler = FakeShareIntentHandler(), + ), + oAuthActionFlow = FakeOAuthActionFlow(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), + analyticsService = FakeAnalyticsService(), + analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + appCoroutineScope = backgroundScope, + ) + } +} + +private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" + +private val A_LOGIN_PARAMS = LoginParams( + accountProvider = "example.com", + loginHint = "mxid:@alice:example.com", +) + +private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( + accountProvider = A_LOGIN_PARAMS.accountProvider, + loginHint = A_LOGIN_PARAMS.loginHint, +) + +/** + * The presenters are only used when the [Node] is rendered, which does not happen in these tests. + */ +private class NotUsedPresenter : Presenter { + @Composable + override fun present(): T = error("Not used") +} + +private class FakeCacheService : CacheService { + override val clearedCacheEventFlow = emptyFlow() +} + +private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { + override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") +} + +private class FakeOAuthActionFlow : OAuthActionFlow { + override fun post(oAuthAction: OAuthAction) = Unit + override suspend fun collect(collector: FlowCollector) = Unit + override fun reset() = Unit +} + +private class FakeAccountProviderAccessControl : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = true +} + +private class FakeBugReportEntryPoint : BugReportEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: BugReportEntryPoint.Callback): Node = node(buildContext) {} +} + +private class FakeSignedOutEntryPoint : SignedOutEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, params: SignedOutEntryPoint.Params): Node = node(buildContext) {} +} + +private class FakeAccountSelectEntryPoint : AccountSelectEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: AccountSelectEntryPoint.Callback): Node = node(buildContext) {} +} + +private class FakeLoginEntryPoint : LoginEntryPoint { + var params: LoginEntryPoint.Params? = null + private set + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: LoginEntryPoint.Params, + callback: LoginEntryPoint.Callback, + ): Node { + this.params = params + return node(buildContext) {} + } +} + +private class FakeNodeFactoriesBindings( + private val nodeFactories: Map, AssistedNodeFactory<*>>, +) : NodeFactoriesBindings { + override fun nodeFactories() = nodeFactories +} + +/** + * [RootFlowNode] creates its children using the dependency injection graph of its ancestors, so it needs a parent + * owning a graph able to create a [NotLoggedInFlowNode]. + */ +private class FakeParentNode( + override val graph: Any, +) : ParentNode( + navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), + buildContext = BuildContext.root(savedStateMap = null), +), + DependencyInjectionGraphOwner { + override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} + + @Composable + override fun View(modifier: Modifier) = Unit +} From 8bdce7cfd6803fecf7a079537491413ea2e17312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 14:18:06 +0200 Subject: [PATCH 065/118] Add tests --- .../impl/roomlist/RoomListPresenterTest.kt | 9 + .../home/impl/roomlist/RoomListViewTest.kt | 2 +- .../impl/search/GlobalSearchPresenterTest.kt | 237 ++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt 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 64dfc46ee2..8cfc47fe43 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 @@ -18,9 +18,11 @@ import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFact import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.search.aRoomListSearchState +import io.element.android.features.home.impl.search.createGlobalSearchPresenter import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState import io.element.android.features.invite.api.SeenInvitesStore @@ -80,6 +82,7 @@ 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.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -680,6 +683,12 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, + globalSearchPresenterFactory = object : GlobalSearchPresenter.Factory { + override fun create(coroutineScope: CoroutineScope) = createGlobalSearchPresenter( + matrixClient = client, + coroutineScope = coroutineScope, + ) + }, filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, 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 773f428917..bd18cd903e 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 @@ -277,7 +277,7 @@ private fun AndroidComposeUiTest.setRoomListView( setSafeContent { HomeView( homeState = aHomeState(roomListState = state), - onRoomClick = onRoomClick, + onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt new file mode 100644 index 0000000000..7469ea4e84 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -0,0 +1,237 @@ +/* + * 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.search + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.search.FakeMessageSearch +import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.Optional + +class GlobalSearchPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.isEnabled).isFalse() + assertThat(state.isSearchActive).isFalse() + assertThat(state.queryState.text.toString()).isEmpty() + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) + assertThat(state.results).isEqualTo(AsyncData.Uninitialized) + } + } + } + + @Test + fun `present - isEnabled reflects the feature flag`() = runTest { + val presenter = createGlobalSearchPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MessageSearch.key to true), + ), + ) + presenter.test { + // isEnabled is emitted asynchronously via produceState, so it starts as false and flips to true + var state = awaitItem() + while (!state.isEnabled) { + state = awaitItem() + } + assertThat(state.isEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggle search visibility`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isTrue() + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + } + } + } + + @Test + fun `present - update target`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) + state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + } + awaitItem().let { state -> + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.MESSAGES) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - clear query`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + val initialState = awaitItem() + initialState.queryState.edit { append("A query") } + awaitItem().let { state -> + assertThat(state.queryState.text.toString()).isEqualTo("A query") + state.eventSink(GlobalSearchEvent.ClearQuery) + } + awaitItem().let { state -> + assertThat(state.queryState.text.toString()).isEmpty() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - room search results are emitted when the room list changes`() = runTest { + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(createRoomListLambda = { roomList }) + val presenter = createGlobalSearchPresenter(roomListService = roomListService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized) + + initialState.queryState.edit { append("A query") } + // Let the query be propagated to the data source before emitting results + testScheduler.advanceUntilIdle() + roomList.summaries.emit(listOf(aRoomSummary())) + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results + assertThat(results).hasSize(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - message search results are mapped when the message search emits`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + testScheduler.advanceUntilIdle() + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results + assertThat(results).hasSize(1) + assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + + successState.eventSink(GlobalSearchEvent.UpdateVisibleRange(IntRange(0, 0))) + runCurrent() + testScheduler.advanceUntilIdle() + + assertThat(messageSearch.paginateCallCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } +} + +internal fun TestScope.createGlobalSearchPresenter( + roomListService: RoomListService = FakeRoomListService(), + messageSearchService: MessageSearchService = FakeMessageSearchService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + latestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(), + dateFormatter: DateFormatter = FakeDateFormatter(), + fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), + permalinkParser: PermalinkParser = FakePermalinkParser(), + matrixClient: MatrixClient = FakeMatrixClient(), + coroutineScope: CoroutineScope = backgroundScope, +): GlobalSearchPresenter { + return GlobalSearchPresenter( + coroutineScope = coroutineScope, + roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { + override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource { + return RoomListSearchDataSource( + coroutineScope = coroutineScope, + roomListService = roomListService, + coroutineDispatchers = testCoroutineDispatchers(), + roomSummaryFactory = aRoomListRoomSummaryFactory(), + ) + } + }, + messageSearchService = messageSearchService, + featureFlagService = featureFlagService, + latestEventFormatter = latestEventFormatter, + dateFormatter = dateFormatter, + fileSizeFormatter = fileSizeFormatter, + permalinkParser = permalinkParser, + coroutineDispatchers = testCoroutineDispatchers(), + matrixClient = matrixClient, + ) +} From f854fbd70a200efc459ae8240b00caf85ca018a1 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 12 Aug 2026 12:06:08 +0000 Subject: [PATCH 066/118] Update screenshots --- .../features.home.impl.search_GlobalSearchView_Day_0_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_1_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_2_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_3_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_4_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_5_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_0_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_1_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_2_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_3_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_4_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_5_en.png | 3 +++ .../images/features.home.impl_HomeView_Day_12_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_12_en.png | 4 ++-- 14 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png new file mode 100644 index 0000000000..955af7bb9a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a856467fba0134aefcad2428493ce9169fccf90705b71d540c2eed935d9b625e +size 22639 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png new file mode 100644 index 0000000000..a4fa915963 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70386b8f6a8998f246819c6d643543babcf41ba72db86db40559a755876c7650 +size 12144 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png new file mode 100644 index 0000000000..0a3484d59e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1c9b463f6fa8fa8c1e9c8a776734879201f506f4cad37ecdba05baabe49b1b3 +size 26668 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png new file mode 100644 index 0000000000..a8bf17e259 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b58ab253d632a15dd5274351ec47dd293099a3fbf7dcac16103d8e63423b09b9 +size 73864 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png new file mode 100644 index 0000000000..ca2936923c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc3946915978e17cf404469273a5ff174c12f53da8fc3cff53ac6df8721e0182 +size 23522 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png new file mode 100644 index 0000000000..cfef0a6883 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad20c730bb320093a5c639938430e6851244e4425b46f3ae10262d9ca96215a4 +size 21972 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png new file mode 100644 index 0000000000..ec806ea229 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb20125a38d4d9436bee64de8f8d09d4afa372a72b7e33325b8d69f5b5760cdd +size 11694 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png new file mode 100644 index 0000000000..aa0fd55433 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20701c8eca52f816c18c788f6ecef485255e3059b5790fc1705c52ede5d75566 +size 26708 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png new file mode 100644 index 0000000000..004b62009e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07388f4fed1ecfd51aa2c98c0014b08aee983b50e691edef95c9d290c2c948aa +size 72281 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png new file mode 100644 index 0000000000..2864a9ffc8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e305c212a008854c19255f91824631d55d9fb523799ecad9b50039aa8b178323 +size 23053 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png index 11ffe943aa..b5f80f2faf 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a1e3fcf68d17303f7082f2f0ee124a166778bad097a9e9e49e4a2de3c63c506 -size 5700 +oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 +size 67694 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png index f57f515cee..fe171e26b7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa9cd96bd8827121d24abaa44055485e34d5f8f3ee24c845a46247a094ca5b83 -size 5566 +oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 +size 62572 From 8d4c7c868ebdeb8b551776e0d3f432adbdd889cc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 14:20:20 +0200 Subject: [PATCH 067/118] Cleanup test --- .../test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index e749ffa492..9710b72df7 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -43,7 +43,6 @@ import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient @@ -162,7 +161,7 @@ class RootFlowNodeTest : RobolectricTest() { signedOutEntryPoint = FakeSignedOutEntryPoint(), accountSelectEntryPoint = FakeAccountSelectEntryPoint(), intentResolver = IntentResolver( - deeplinkParser = DeeplinkParser { null }, + deeplinkParser = { null }, loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS }, oAuthIntentResolver = FakeOAuthIntentResolver { null }, permalinkParser = FakePermalinkParser(), From b3f53da7d99454f25708c0e091b3331555adb270 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 14:55:03 +0200 Subject: [PATCH 068/118] Add exception for Konsist test. --- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 93b3200acc..76d0dfd582 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -104,6 +104,7 @@ class KonsistClassNameTest { "FakeImageLoader", "FakeKeyStore", "FakeListenableFuture", + "FakeParentNode", ) .assertTrue { val interfaceName = it.name From f0e575e25efb45aa1d2eb48dd7a7259d11787a7e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:11:00 +0200 Subject: [PATCH 069/118] Move the fake entry points of RootFlowNodeTest to the test modules The fakes were declared in RootFlowNodeTest, so other tests could not use them. Move each of them next to the API it fakes, following the existing convention of a createNodeResult lambda defaulting to lambdaError(): - FakeLoginEntryPoint in features/login/test - FakeBugReportEntryPoint in features/rageshake/test - FakeSignedOutEntryPoint in features/signedout/test (new module) - FakeAccountSelectEntryPoint in libraries/accountselect/test (new module) The login and rageshake test modules did not have Appyx on their classpath, so add the architecture module to their dependencies. Co-Authored-By: Claude Opus 5 (1M context) --- appnav/build.gradle.kts | 2 + .../android/appnav/RootFlowNodeTest.kt | 58 +++++++------------ features/login/test/build.gradle.kts | 1 + .../login/test/FakeLoginEntryPoint.kt | 24 ++++++++ features/rageshake/test/build.gradle.kts | 1 + .../rageshake/test/FakeBugReportEntryPoint.kt | 23 ++++++++ features/signedout/test/build.gradle.kts | 19 ++++++ .../signedout/test/FakeSignedOutEntryPoint.kt | 23 ++++++++ libraries/accountselect/test/build.gradle.kts | 19 ++++++ .../test/FakeAccountSelectEntryPoint.kt | 23 ++++++++ 10 files changed, 156 insertions(+), 37 deletions(-) create mode 100644 features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt create mode 100644 features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt create mode 100644 features/signedout/test/build.gradle.kts create mode 100644 features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt create mode 100644 libraries/accountselect/test/build.gradle.kts create mode 100644 libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 64aa2a6fc5..8e21f247ee 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.features.login.test) testImplementation(projects.features.share.test) + testImplementation(projects.libraries.accountselect.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrixmedia.test) @@ -75,6 +76,7 @@ dependencies { testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.signedout.test) testImplementation(projects.services.apperror.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.analytics.test) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 9710b72df7..1661782871 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -33,13 +33,14 @@ import io.element.android.appnav.root.RootPresenter import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginIntentResolver import io.element.android.features.preferences.api.CacheService -import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.share.test.FakeShareIntentHandler -import io.element.android.features.signedout.api.SignedOutEntryPoint -import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.features.signedout.test.FakeSignedOutEntryPoint +import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.Presenter @@ -81,8 +82,13 @@ class RootFlowNodeTest : RobolectricTest() { @Test fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest { - val loginEntryPoint = FakeLoginEntryPoint() - val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + var loginEntryPointParams: LoginEntryPoint.Params? = null + val rootFlowNode = createRootFlowNode( + loginEntryPoint = FakeLoginEntryPoint { buildContext, params -> + loginEntryPointParams = params + node(buildContext) {} + }, + ) rootFlowNode.parentNodeTestHelper() // The intent is handled first, this is what happens when the deep link cold starts the app. rootFlowNode.handleIntent(aLoginIntent()) @@ -90,13 +96,18 @@ class RootFlowNodeTest : RobolectricTest() { // Then the first nav state emission lands, it must not override the login params. runCurrent() assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) - assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) } @Test fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest { - val loginEntryPoint = FakeLoginEntryPoint() - val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + var loginEntryPointParams: LoginEntryPoint.Params? = null + val rootFlowNode = createRootFlowNode( + loginEntryPoint = FakeLoginEntryPoint { buildContext, params -> + loginEntryPointParams = params + node(buildContext) {} + }, + ) rootFlowNode.parentNodeTestHelper() // The nav state is observed first, this is what happens when the app is already running. runCurrent() @@ -104,13 +115,13 @@ class RootFlowNodeTest : RobolectricTest() { rootFlowNode.handleIntent(aLoginIntent()) runCurrent() assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) - assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) } private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK)) private fun TestScope.createRootFlowNode( - loginEntryPoint: LoginEntryPoint = FakeLoginEntryPoint(), + loginEntryPoint: LoginEntryPoint, sessionStore: SessionStore = InMemorySessionStore(), ): RootFlowNode { val matrixSessionCache = MatrixSessionCache( @@ -215,33 +226,6 @@ private class FakeAccountProviderAccessControl : AccountProviderAccessControl { override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = true } -private class FakeBugReportEntryPoint : BugReportEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext, callback: BugReportEntryPoint.Callback): Node = node(buildContext) {} -} - -private class FakeSignedOutEntryPoint : SignedOutEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext, params: SignedOutEntryPoint.Params): Node = node(buildContext) {} -} - -private class FakeAccountSelectEntryPoint : AccountSelectEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext, callback: AccountSelectEntryPoint.Callback): Node = node(buildContext) {} -} - -private class FakeLoginEntryPoint : LoginEntryPoint { - var params: LoginEntryPoint.Params? = null - private set - - override fun createNode( - parentNode: Node, - buildContext: BuildContext, - params: LoginEntryPoint.Params, - callback: LoginEntryPoint.Callback, - ): Node { - this.params = params - return node(buildContext) {} - } -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts index 7073648dd2..4cbf0910db 100644 --- a/features/login/test/build.gradle.kts +++ b/features/login/test/build.gradle.kts @@ -15,6 +15,7 @@ android { } dependencies { + implementation(projects.libraries.architecture) implementation(projects.features.login.api) implementation(projects.tests.testutils) } diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt new file mode 100644 index 0000000000..2c94144890 --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * 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.login.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLoginEntryPoint( + private val createNodeResult: (BuildContext, LoginEntryPoint.Params) -> Node = { _, _ -> lambdaError() }, +) : LoginEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: LoginEntryPoint.Params, + callback: LoginEntryPoint.Callback, + ): Node = createNodeResult(buildContext, params) +} diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts index afc5e51499..faad812d73 100644 --- a/features/rageshake/test/build.gradle.kts +++ b/features/rageshake/test/build.gradle.kts @@ -14,6 +14,7 @@ android { } dependencies { + implementation(projects.libraries.architecture) implementation(projects.features.rageshake.api) implementation(libs.coroutines.core) implementation(projects.tests.testutils) diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt new file mode 100644 index 0000000000..d6e1a47368 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * 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.rageshake.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeBugReportEntryPoint( + private val createNodeResult: (BuildContext, BugReportEntryPoint.Callback) -> Node = { _, _ -> lambdaError() }, +) : BugReportEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: BugReportEntryPoint.Callback, + ): Node = createNodeResult(buildContext, callback) +} diff --git a/features/signedout/test/build.gradle.kts b/features/signedout/test/build.gradle.kts new file mode 100644 index 0000000000..5bbfb33c1a --- /dev/null +++ b/features/signedout/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.signedout.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.features.signedout.api) + implementation(projects.tests.testutils) +} diff --git a/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt new file mode 100644 index 0000000000..863b946871 --- /dev/null +++ b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * 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.signedout.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSignedOutEntryPoint( + private val createNodeResult: (BuildContext, SignedOutEntryPoint.Params) -> Node = { _, _ -> lambdaError() }, +) : SignedOutEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SignedOutEntryPoint.Params, + ): Node = createNodeResult(buildContext, params) +} diff --git a/libraries/accountselect/test/build.gradle.kts b/libraries/accountselect/test/build.gradle.kts new file mode 100644 index 0000000000..c38cd8ac69 --- /dev/null +++ b/libraries/accountselect/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.accountselect.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt new file mode 100644 index 0000000000..acc5ba10df --- /dev/null +++ b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountSelectEntryPoint( + private val createNodeResult: (BuildContext, AccountSelectEntryPoint.Callback) -> Node = { _, _ -> lambdaError() }, +) : AccountSelectEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: AccountSelectEntryPoint.Callback, + ): Node = createNodeResult(buildContext, callback) +} From 67500edafa1fbe12e9b632be0beb60969517c0ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:17:18 +0200 Subject: [PATCH 070/118] Move FakeCacheService to the preferences test module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it next to the API it fakes, and let the cleared cache event flow be provided by the caller, so that a test can emit events. Co-Authored-By: Claude Opus 5 (1M context) --- appnav/build.gradle.kts | 1 + .../element/android/appnav/RootFlowNodeTest.kt | 8 +------- features/preferences/test/build.gradle.kts | 2 ++ .../preferences/test/FakeCacheService.kt | 17 +++++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 8e21f247ee..12b95d9a09 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { testImplementation(projects.features.forward.test) testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.features.preferences.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.signedout.test) testImplementation(projects.services.apperror.test) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 1661782871..9cba1d7934 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -35,7 +35,7 @@ import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginIntentResolver -import io.element.android.features.preferences.api.CacheService +import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.share.test.FakeShareIntentHandler @@ -47,7 +47,6 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -65,7 +64,6 @@ import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -208,10 +206,6 @@ private class NotUsedPresenter : Presenter { override fun present(): T = error("Not used") } -private class FakeCacheService : CacheService { - override val clearedCacheEventFlow = emptyFlow() -} - private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") } diff --git a/features/preferences/test/build.gradle.kts b/features/preferences/test/build.gradle.kts index a066fe4707..5b8969796a 100644 --- a/features/preferences/test/build.gradle.kts +++ b/features/preferences/test/build.gradle.kts @@ -16,5 +16,7 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.features.preferences.api) + implementation(projects.libraries.matrix.api) + implementation(libs.coroutines.core) implementation(projects.tests.testutils) } diff --git a/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt new file mode 100644 index 0000000000..9696a2050b --- /dev/null +++ b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt @@ -0,0 +1,17 @@ +/* + * 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.preferences.test + +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class FakeCacheService( + override val clearedCacheEventFlow: Flow = emptyFlow(), +) : CacheService From 5bdaea3f6e0bb541e0b45a264728672952387124 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:20:33 +0200 Subject: [PATCH 071/118] Move FakeAccountProviderAccessControl to the login test module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it next to the API it fakes, in a package mirroring the one of the API, and let the result be provided by the caller, so that the case where the account provider is not allowed can be tested too. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 8 ++------ .../FakeAccountProviderAccessControl.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 9cba1d7934..7562356ffd 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -32,9 +32,9 @@ import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams -import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.logs.FakeAnnouncementService @@ -150,7 +150,7 @@ class RootFlowNodeTest : RobolectricTest() { ), plugins = emptyList(), sessionStore = sessionStore, - accountProviderAccessControl = FakeAccountProviderAccessControl(), + accountProviderAccessControl = FakeAccountProviderAccessControl { true }, navStateFlowFactory = RootNavStateFlowFactory( sessionStore = sessionStore, cacheService = FakeCacheService(), @@ -216,10 +216,6 @@ private class FakeOAuthActionFlow : OAuthActionFlow { override fun reset() = Unit } -private class FakeAccountProviderAccessControl : AccountProviderAccessControl { - override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = true -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt new file mode 100644 index 0000000000..9ebd3be7fc --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt @@ -0,0 +1,19 @@ +/* + * 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.login.test.accesscontrol + +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountProviderAccessControl( + private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() }, +) : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean { + return isAllowedToConnectToAccountProviderResult(accountProviderUrl) + } +} From be124610f047b97634f0d24a93e5439c6b873c66 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:28:22 +0200 Subject: [PATCH 072/118] Reuse the existing FakeOAuthActionFlow in RootFlowNodeTest RootFlowNodeTest was declaring its own no-op fake, while the oauth test module already provides one. Use it, and move it out of the customtab package, since it is not specific to the custom tabs. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/element/android/appnav/RootFlowNodeTest.kt | 10 +--------- .../features/login/impl/DefaultLoginEntryPointTest.kt | 2 +- .../ConfirmAccountProviderPresenterTest.kt | 2 +- .../impl/screens/onboarding/OnBoardingPresenterTest.kt | 2 +- .../oauth/test/{customtab => }/FakeOAuthActionFlow.kt | 2 +- 5 files changed, 5 insertions(+), 13 deletions(-) rename libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/{customtab => }/FakeOAuthActionFlow.kt (94%) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 7562356ffd..0bb8cb171b 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -51,9 +51,8 @@ import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder -import io.element.android.libraries.oauth.api.OAuthAction -import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -63,7 +62,6 @@ import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -210,12 +208,6 @@ private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") } -private class FakeOAuthActionFlow : OAuthActionFlow { - override fun post(oAuthAction: OAuthAction) = Unit - override suspend fun collect(collector: FlowCollector) = Unit - override fun reset() = Unit -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index 4e040a7556..8a0bf4c307 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -16,7 +16,7 @@ import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.features.preferences.test.FakePreferencesEntryPoint -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode import kotlinx.coroutines.test.runTest diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 229fc375a2..1d9c32c0a0 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 2b64ae8b71..3c2e4b574f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.test.A_LOGIN_HINT import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oauth.api.OAuthActionFlow -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor diff --git a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt similarity index 94% rename from libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt rename to libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt index 5a5ca4369e..49abe58622 100644 --- a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt +++ b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.oauth.test.customtab +package io.element.android.libraries.oauth.test import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow From d71f691d752c9d9004466c2c1359562eb37d0c28 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:41:30 +0200 Subject: [PATCH 073/118] Move the session related classes to a session package MatrixSessionCache and SyncOrchestrator were in the di package, which does not describe what they do. Move them, along with their tests, to a session package, and extract FakeSyncOrchestratorFactory from RootFlowNodeTest so that other tests can use it. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/element/android/appnav/RootFlowNode.kt | 2 +- .../appnav/root/RootNavStateFlowFactory.kt | 2 +- .../{di => session}/MatrixSessionCache.kt | 2 +- .../{di => session}/SyncOrchestrator.kt | 2 +- .../android/appnav/RootFlowNodeTest.kt | 12 +++--------- .../android/appnav/SyncOrchestratorTest.kt | 2 +- .../session/FakeSyncOrchestratorFactory.kt | 19 +++++++++++++++++++ .../{di => session}/MatrixSessionCacheTest.kt | 2 +- 8 files changed, 28 insertions(+), 15 deletions(-) rename appnav/src/main/kotlin/io/element/android/appnav/{di => session}/MatrixSessionCache.kt (99%) rename appnav/src/main/kotlin/io/element/android/appnav/{di => session}/SyncOrchestrator.kt (99%) create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt rename appnav/src/test/kotlin/io/element/android/appnav/{di => session}/MatrixSessionCacheTest.kt (99%) 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 19ec427303..c9deaeb3b3 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -32,7 +32,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.annotations.ContributesNode -import io.element.android.appnav.di.MatrixSessionCache import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.room.RoomFlowNode @@ -40,6 +39,7 @@ import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 9a91e9765b..8b02752a64 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -11,7 +11,7 @@ package io.element.android.appnav.root import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject -import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.preferences.api.CacheService import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt similarity index 99% rename from appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt rename to appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt index a4bccfd6d7..ffae5f2b17 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import androidx.annotation.VisibleForTesting import com.bumble.appyx.core.state.MutableSavedStateMap diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt similarity index 99% rename from appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt rename to appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt index 35a6a224ae..3a72c10ab7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import androidx.annotation.VisibleForTesting import dev.zacsweers.metro.Assisted diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 0bb8cb171b..c487de1d25 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -25,11 +25,11 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl import com.google.common.truth.Truth.assertThat -import io.element.android.appnav.di.MatrixSessionCache -import io.element.android.appnav.di.SyncOrchestrator import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter +import io.element.android.appnav.session.FakeSyncOrchestratorFactory +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginEntryPoint @@ -46,13 +46,12 @@ import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder -import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.oauth.test.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -60,7 +59,6 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.robolectric.RobolectricTest -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent @@ -204,10 +202,6 @@ private class NotUsedPresenter : Presenter { override fun present(): T = error("Not used") } -private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { - override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index ab19ae0b54..32d0ec8f56 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -8,7 +8,7 @@ package io.element.android.appnav -import io.element.android.appnav.di.SyncOrchestrator +import io.element.android.appnav.session.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.sync.SyncState diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt new file mode 100644 index 0000000000..586c8f0804 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt @@ -0,0 +1,19 @@ +/* + * 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.appnav.session + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.CoroutineScope + +class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { + override fun create( + matrixClient: MatrixClient, + sessionCoroutineScope: CoroutineScope, + ): SyncOrchestrator = lambdaError() +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt similarity index 99% rename from appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt index cba8d06f90..bf315ddb01 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat From e85c5041a476ba589eaa3bea0f947cccd5e39733 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:51:30 +0200 Subject: [PATCH 074/118] Move NotUsedPresenter to the testutils module The fake presenter was declared in RootFlowNodeTest, so other tests could not use it. Move it to the testutils module, in a new presenter package, along with MutablePresenter. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 10 +--------- .../{ => presenter}/MutablePresenter.kt | 2 +- .../testutils/presenter/NotUsedPresenter.kt | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) rename tests/testutils/src/main/kotlin/io/element/android/tests/testutils/{ => presenter}/MutablePresenter.kt (93%) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index c487de1d25..acc57e8023 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -43,7 +43,6 @@ import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory import io.element.android.libraries.architecture.NodeFactoriesBindings -import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata @@ -58,6 +57,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -194,14 +194,6 @@ private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( loginHint = A_LOGIN_PARAMS.loginHint, ) -/** - * The presenters are only used when the [Node] is rendered, which does not happen in these tests. - */ -private class NotUsedPresenter : Presenter { - @Composable - override fun present(): T = error("Not used") -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt similarity index 93% rename from tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt rename to tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt index ae8768e0b0..13fcc920c2 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.tests.testutils +package io.element.android.tests.testutils.presenter import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt new file mode 100644 index 0000000000..bd786c2484 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt @@ -0,0 +1,19 @@ +/* + * 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.tests.testutils.presenter + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter + +/** + * A presenter that cannot be used, util for testing Nodes. + */ +class NotUsedPresenter : Presenter { + @Composable + override fun present(): T = error("Not used") +} From 8e4b2145d5f55d76ef9e00a00cd7f259f46215db Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:56:39 +0200 Subject: [PATCH 075/118] Move FakeNodeFactoriesBindings to the testutils module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it to the testutils module, in a new node package. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 9 +-------- .../node/FakeNodeFactoriesBindings.kt | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index acc57e8023..c39b468cac 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -42,7 +42,6 @@ import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory -import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata @@ -57,6 +56,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -65,7 +65,6 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -import kotlin.reflect.KClass class RootFlowNodeTest : RobolectricTest() { @get:Rule @@ -194,12 +193,6 @@ private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( loginHint = A_LOGIN_PARAMS.loginHint, ) -private class FakeNodeFactoriesBindings( - private val nodeFactories: Map, AssistedNodeFactory<*>>, -) : NodeFactoriesBindings { - override fun nodeFactories() = nodeFactories -} - /** * [RootFlowNode] creates its children using the dependency injection graph of its ancestors, so it needs a parent * owning a graph able to create a [NotLoggedInFlowNode]. diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt new file mode 100644 index 0000000000..cee2c74960 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt @@ -0,0 +1,19 @@ +/* + * 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.tests.testutils.node + +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.architecture.NodeFactoriesBindings +import kotlin.reflect.KClass + +class FakeNodeFactoriesBindings( + private val nodeFactories: Map, AssistedNodeFactory<*>>, +) : NodeFactoriesBindings { + override fun nodeFactories() = nodeFactories +} From a6a09a8be0204c90d639657e3155d95b16dfadf1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 16:01:31 +0200 Subject: [PATCH 076/118] Move FakeParentNode to the testutils module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it to the testutils module, in the node package, and make its documentation generic. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 24 +------------ .../tests/testutils/node/FakeParentNode.kt | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 23 deletions(-) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index c39b468cac..0916c4f09a 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -12,13 +12,8 @@ package io.element.android.appnav import android.content.Intent import android.net.Uri import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.AncestryInfo import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.node import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.testing.junit4.util.MainDispatcherRule @@ -42,7 +37,6 @@ import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory -import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService @@ -57,6 +51,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings +import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -192,20 +187,3 @@ private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( accountProvider = A_LOGIN_PARAMS.accountProvider, loginHint = A_LOGIN_PARAMS.loginHint, ) - -/** - * [RootFlowNode] creates its children using the dependency injection graph of its ancestors, so it needs a parent - * owning a graph able to create a [NotLoggedInFlowNode]. - */ -private class FakeParentNode( - override val graph: Any, -) : ParentNode( - navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), - buildContext = BuildContext.root(savedStateMap = null), -), - DependencyInjectionGraphOwner { - override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} - - @Composable - override fun View(modifier: Modifier) = Unit -} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt new file mode 100644 index 0000000000..1375043d48 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt @@ -0,0 +1,34 @@ +/* + * 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.tests.testutils.node + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import io.element.android.libraries.di.DependencyInjectionGraphOwner + +/** + * A Node creates its children using the dependency injection graph of its ancestors, so a Node under test + * needs such a parent to be able to create its children. + */ +class FakeParentNode( + override val graph: Any, +) : ParentNode( + navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), + buildContext = BuildContext.root(savedStateMap = null), +), + DependencyInjectionGraphOwner { + override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} + + @Composable + override fun View(modifier: Modifier) = Unit +} From 1209d7e283af108172b14b1ddfec2d12c798904d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 16:02:35 +0200 Subject: [PATCH 077/118] Move the test constants to the top of RootFlowNodeTest Declaring them before the test class makes them easier to find. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 0916c4f09a..fea30c8019 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -61,6 +61,18 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" + +private val A_LOGIN_PARAMS = LoginParams( + accountProvider = "example.com", + loginHint = "mxid:@alice:example.com", +) + +private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( + accountProvider = A_LOGIN_PARAMS.accountProvider, + loginHint = A_LOGIN_PARAMS.loginHint, +) + class RootFlowNodeTest : RobolectricTest() { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @@ -175,15 +187,3 @@ class RootFlowNodeTest : RobolectricTest() { ) } } - -private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" - -private val A_LOGIN_PARAMS = LoginParams( - accountProvider = "example.com", - loginHint = "mxid:@alice:example.com", -) - -private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( - accountProvider = A_LOGIN_PARAMS.accountProvider, - loginHint = A_LOGIN_PARAMS.loginHint, -) From d710b2351339b056837fedd8ba9801b4577332e6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:25:14 +0200 Subject: [PATCH 078/118] Update dependency io.element.android:element-call-embedded to v0.24.0 (#7474) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e1751f3bc..97fe235b96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -242,7 +242,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.23.0" +element_call_embedded = "io.element.android:element-call-embedded:0.24.0" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } From c10e84923b01c40759841131d58600e2c79e0118 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:02:02 +0200 Subject: [PATCH 079/118] Let user see an error dialog in the case a message fails to be sent. Closes #7470 --- .../messages/impl/MessagesPresenter.kt | 14 +++++++ .../features/messages/impl/MessagesView.kt | 36 +++++++++++++++- .../actionlist/model/TimelineItemAction.kt | 3 ++ .../messages/impl/timeline/TimelineEvent.kt | 3 ++ .../impl/timeline/TimelinePresenter.kt | 41 ++++++++++++++++++ .../messages/impl/timeline/TimelineState.kt | 2 + .../impl/timeline/TimelineStateProvider.kt | 3 ++ .../components/TimelineEventTimestampView.kt | 3 +- .../sendfailure/SendFailureDialogState.kt | 21 ++++++++++ .../sendfailure/SendFailureDialogView.kt | 42 +++++++++++++++++++ 10 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index dd3afb0ea6..4af0c05955 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -400,9 +400,23 @@ class MessagesPresenter( TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit + TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) } } + private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) { + val sendHandle = targetEvent.sendhandle ?: return Unit.also { + Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}") + } + sendHandle.retry() + .onSuccess { + Timber.d("Succeed to add the message back to the send queue") + } + .onFailure { + Timber.e(it, "Failed to add the message back to the send queue") + } + } + private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) { timelineController.invokeOnCurrentTimeline { editCaption( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index ad71ada70d..0476d0de46 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView import io.element.android.features.messages.impl.topbars.MessagesViewTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog @@ -429,6 +430,31 @@ fun MessagesView( }, state = state.linkState, ) + + SendFailureDialogView( + sendFailureDialogState = state.timelineState.sendFailureDialogState, + onDismiss = { + state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) + }, + onRetry = { event -> + state.eventSink( + MessagesEvent.HandleAction( + action = TimelineItemAction.RetrySending, + event = event, + ) + ) + state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) + }, + onRemoveMessage = { event -> + state.eventSink( + MessagesEvent.HandleAction( + action = TimelineItemAction.Redact, + event = event, + ) + ) + state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) + }, + ) } @Composable @@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents( when { state.successorRoom != null -> { SuccessorRoomBanner( - modifier = Modifier.fillMaxWidth().padding(contentPadding), + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick ) } state.userEventPermissions.canSendMessage -> { - Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + ) { // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). if (state.composerState.suggestions.isEmpty() && state.composerState.textEditorState is TextEditorState.Markdown) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 25e75beb99..3c64223e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -36,4 +36,7 @@ enum class TimelineItemAction( EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end), Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), + + // Note: this action is not used in UI for now + RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid), } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index cfc3672ca3..97608e15fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -34,6 +34,9 @@ sealed interface TimelineEvent { */ sealed interface TimelineItemEvent : TimelineEvent + data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent + data object HideSendFailureDialog : TimelineEvent + data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 7cd0d018d2..fb64e0c096 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems @@ -142,6 +144,7 @@ class TimelinePresenter( val newEventState = remember { mutableStateOf(NewEventState.None) } val messageShieldDialogData: MutableState = remember { mutableStateOf(null) } + var sendFailureDialogState by remember { mutableStateOf(SendFailureDialogState.Hidden) } // Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a // [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for @@ -244,6 +247,43 @@ class TimelinePresenter( suppressJumpToUnread.value = true } is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData + is TimelineEvent.ShowSendFailureDialog -> { + val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return + when (sendStateFailure) { + is LocalEventSendState.Failed.VerifiedUser -> { + // defer to the resolveVerifiedUserSendFailureState to handle this case, + // as it will show a dialog with the right information + resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) + } + is LocalEventSendState.Failed.InvalidMimeType -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + message = "Invalid mime type: ${sendStateFailure.mimeType}", + ) + } + LocalEventSendState.Failed.MissingMediaContent -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + message = "Missing media content", + ) + } + LocalEventSendState.Failed.SendingFromUnverifiedDevice -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + message = "Sending from unverified device", + ) + } + is LocalEventSendState.Failed.Unknown -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + message = sendStateFailure.error.takeIf { it.isNotBlank() } ?: "Unknown error", + ) + } + } + } + is TimelineEvent.HideSendFailureDialog -> { + sendFailureDialogState = SendFailureDialogState.Hidden + } is TimelineEvent.ComputeVerifiedUserSendFailure -> { resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) } @@ -408,6 +448,7 @@ class TimelinePresenter( focusRequestState = focusRequestState.value, messageShieldDialogData = messageShieldDialogData.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + sendFailureDialogState = sendFailureDialogState, displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 7836c17563..1ebc96f15f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso import io.element.android.features.messages.impl.timeline.components.MessageShieldData import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.matrix.api.core.EventId @@ -32,6 +33,7 @@ data class TimelineState( // If not null, info will be rendered in a dialog val messageShieldDialogData: MessageShieldData?, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, + val sendFailureDialogState: SendFailureDialogState, val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 61b5c690a4..89545b5d0b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.roomcall.api.aStandByCallState @@ -57,6 +58,7 @@ fun aTimelineState( isLive: Boolean = true, messageShield: MessageShield? = null, resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), + sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden, displayThreadSummaries: Boolean = false, displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, @@ -78,6 +80,7 @@ fun aTimelineState( focusRequestState = focusRequestState, messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + sendFailureDialogState = sendFailureDialogState, displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index b6af359269..c82be7b10a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -70,10 +70,9 @@ fun TimelineEventTimestampView( } hasError -> Modifier .clickable( - enabled = isVerifiedUserSendFailure, onClickLabel = onClickLabel, ) { - eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event)) + eventSink(TimelineEvent.ShowSendFailureDialog(event)) } else -> Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt new file mode 100644 index 0000000000..c3d3b2d9b0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +@Immutable +sealed interface SendFailureDialogState { + data object Hidden : SendFailureDialogState + + data class Show( + val event: TimelineItem.Event, + val message: String, + ) : SendFailureDialogState +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt new file mode 100644 index 0000000000..771464112a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -0,0 +1,42 @@ +/* + * 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.messages.impl.timeline.sendfailure + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SendFailureDialogView( + sendFailureDialogState: SendFailureDialogState, + onDismiss: () -> Unit, + onRetry: (TimelineItem.Event) -> Unit, + onRemoveMessage: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + when (sendFailureDialogState) { + is SendFailureDialogState.Hidden -> Unit + is SendFailureDialogState.Show -> { + // Show the dialog with the message and actions + ConfirmationDialog( + modifier = modifier, + title = stringResource(id = CommonStrings.common_sending_failed), + content = sendFailureDialogState.message, + onDismiss = onDismiss, + submitText = stringResource(id = CommonStrings.action_retry), + onSubmitClick = { onRetry(sendFailureDialogState.event) }, + onCancelClick = onDismiss, + thirdButtonText = stringResource(id = CommonStrings.action_remove_message), + onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) }, + ) + } + } +} From 27d3f3dabcce7a69329eeb95efc6a540d2f27900 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:06:07 +0200 Subject: [PATCH 080/118] Add preview for the new dialog --- .../SendFailureDialogStateProvider.kt | 22 +++++++++++++++++++ .../sendfailure/SendFailureDialogView.kt | 15 +++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt new file mode 100644 index 0000000000..b6730f0392 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent + +class SendFailureDialogStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SendFailureDialogState.Hidden, + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + message = "Failed to send message", + ) + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt index 771464112a..0054880a76 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -10,8 +10,10 @@ package io.element.android.features.messages.impl.timeline.sendfailure import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -40,3 +42,16 @@ fun SendFailureDialogView( } } } + +@PreviewsDayNight +@Composable +fun SendFailureDialogViewPreview( + @PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState, +) { + SendFailureDialogView( + sendFailureDialogState = sendFailureDialogState, + onDismiss = {}, + onRetry = {}, + onRemoveMessage = {} + ) +} From 3f2a1d95fcef783a7d03805d25b121fb665ef68d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:41:02 +0200 Subject: [PATCH 081/118] Fix issues and add missing text. --- .../model/TimelineItemActionComparator.kt | 2 + .../components/TimelineEventTimestampView.kt | 5 +- .../sendfailure/SendFailureDialogView.kt | 7 +- .../messages/impl/MessagesPresenterTest.kt | 52 +++++++++ .../messages/impl/MessagesViewTest.kt | 67 +++++++++++ .../impl/timeline/TimelinePresenterTest.kt | 110 +++++++++++++++++- .../impl/timeline/TimelineViewTest.kt | 41 +++++++ .../sendfailure/SendFailureDialogViewTest.kt | 105 +++++++++++++++++ 8 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 0a0d9b17af..2f6fa4e4e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.ViewSource, TimelineItemAction.ReportContent, TimelineItemAction.Redact, + // Not rendered in the action list, but has to be listed here for exhaustiveness. + TimelineItemAction.RetrySending, ) override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index c82be7b10a..f124d45d99 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -56,10 +56,11 @@ fun TimelineEventTimestampView( val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val onClickLabel = when { shield != null -> stringResource(CommonStrings.a11y_view_details) - hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) + isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) + hasError -> stringResource(CommonStrings.a11y_view_details) else -> null } - val clickableModifier = remember(shield, hasError) { + val clickableModifier = remember(event) { when { shield != null -> { Modifier.clickable( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt index 0054880a76..ecab806857 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.ui.strings.CommonStrings @@ -45,13 +46,13 @@ fun SendFailureDialogView( @PreviewsDayNight @Composable -fun SendFailureDialogViewPreview( +internal fun SendFailureDialogViewPreview( @PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState, -) { +) = ElementPreview { SendFailureDialogView( sendFailureDialogState = sendFailureDialogState, onDismiss = {}, onRetry = {}, - onRemoveMessage = {} + onRemoveMessage = {}, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 0a0d310b16..227088f78c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -83,6 +84,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -1043,6 +1045,56 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action retry sending`() = runTest { + val retryLambda = lambdaRecorder> { Result.success(Unit) } + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + retryLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - handle action retry sending - failure is ignored`() = runTest { + val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + retryLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest { + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { null }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + // No op! + } + } + @Test fun `present - handle action view in timeline, it should have no effect`() = runTest { val messageEvent = aMessageEvent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 1350f1dfa0..2a6353de87 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -55,12 +55,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.user.MatrixUser @@ -558,6 +560,71 @@ class MessagesViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem)) } + @Test + fun `clicking on retry in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder() + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_retry) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, timelineItem)) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + + @Test + fun `clicking on remove message in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder() + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_remove_message) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + + @Test + fun `cancelling the send failure dialog emits the expected Event`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder(expectEvents = false) + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_cancel) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + @Test fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest { val aUnicode = "🙈" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3b0794305f..3e4e8bc0ae 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -12,6 +12,8 @@ import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.impl.FakeMessagesNavigator +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -21,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -45,6 +48,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent @@ -1390,6 +1394,109 @@ class TimelinePresenterTest { } } + @Test + fun `present - show and hide send failure dialog`() = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error")) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + awaitItem().also { state -> + assertThat(state.sendFailureDialogState).isEqualTo( + SendFailureDialogState.Show(event = event, message = "An error") + ) + state.eventSink(TimelineEvent.HideSendFailureDialog) + } + awaitItem().also { state -> + assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + } + } + } + + @Test + fun `present - show send failure dialog - unknown error with a blank reason`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.Unknown(error = ""), + expectedMessage = "Unknown error", + ) + } + + @Test + fun `present - show send failure dialog - invalid mime type`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"), + expectedMessage = "Invalid mime type: invalid/mimeType", + ) + } + + @Test + fun `present - show send failure dialog - missing media content`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.MissingMediaContent, + expectedMessage = "Missing media content", + ) + } + + @Test + fun `present - show send failure dialog - sending from unverified device`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice, + expectedMessage = "Sending from unverified device", + ) + } + + private fun assertSendFailureDialogMessage( + sendState: LocalEventSendState.Failed, + expectedMessage: String, + ) = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = sendState) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + assertThat(awaitItem().sendFailureDialogState).isEqualTo( + SendFailureDialogState.Show(event = event, message = expectedMessage) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest { + val resolveEventSinkRecorder = lambdaRecorder { } + val presenter = createTimelinePresenter( + resolveVerifiedUserSendFailurePresenter = { + aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder) + }, + ) + val event = aMessageEvent( + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + // The generic dialog is not displayed, the dedicated one is. + assertThat(consumeItemsUntilTimeout()).isEmpty() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + resolveEventSinkRecorder.assertions() + .isCalledOnce() + .with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event))) + } + } + + @Test + fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + assertThat(consumeItemsUntilTimeout()).isEmpty() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + } + } + @Test fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { val timeline = FakeTimeline( @@ -1589,6 +1696,7 @@ class TimelinePresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, + resolveVerifiedUserSendFailurePresenter: Presenter = { aResolveVerifiedUserSendFailureState() }, ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1602,7 +1710,7 @@ class TimelinePresenterTest { sessionPreferencesStore = sessionPreferencesStore, timelineItemIndexer = timelineItemIndexer, timelineController = TimelineController(room, timeline), - resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, + resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index f733f116d6..ac84d48c95 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType @@ -178,6 +179,46 @@ class TimelineViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) } + @Test + fun `clicking on the send failure indicator emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent( + content = aTimelineItemTextContent(), + sendState = LocalEventSendState.Failed.Unknown("Error"), + ) + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(timelineItem), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + val contentDescription = activity!!.getString(CommonStrings.common_sending_failed) + onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem)) + } + + @Test + fun `clicking on the send failure indicator of a verified user failure emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent( + content = aTimelineItemTextContent(), + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(timelineItem), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + val contentDescription = activity!!.getString(CommonStrings.common_sending_failed) + onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem)) + } + @Test fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt new file mode 100644 index 0000000000..204d367b0f --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt @@ -0,0 +1,105 @@ +/* + * 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.messages.impl.timeline.sendfailure + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.setSafeContent +import org.junit.Test + +class SendFailureDialogViewTest : RobolectricTest() { + @Test + fun `hidden state renders nothing`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = SendFailureDialogState.Hidden, + ) + onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertDoesNotExist() + } + + @Test + fun `show state renders the failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow(message = "A failure reason"), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertExists() + onNodeWithText("A failure reason").assertExists() + } + + @Test + fun `clicking on retry invokes the expected callback`() = runAndroidComposeUiTest { + val state = aSendFailureDialogStateShow() + ensureCalledOnceWithParam(state.event) { callback -> + setSendFailureDialogView( + sendFailureDialogState = state, + onRetry = callback, + ) + clickOn(CommonStrings.action_retry) + } + } + + @Test + fun `clicking on remove message invokes the expected callback`() = runAndroidComposeUiTest { + val state = aSendFailureDialogStateShow() + ensureCalledOnceWithParam(state.event) { callback -> + setSendFailureDialogView( + sendFailureDialogState = state, + onRemoveMessage = callback, + ) + clickOn(CommonStrings.action_remove_message) + } + } + + @Test + fun `clicking on cancel invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow(), + onDismiss = callback, + ) + clickOn(CommonStrings.action_cancel) + } + } + + private fun aSendFailureDialogStateShow( + message: String = "A failure reason", + ) = SendFailureDialogState.Show( + event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown(message)), + message = message, + ) + + private fun AndroidComposeUiTest.setSendFailureDialogView( + sendFailureDialogState: SendFailureDialogState, + onDismiss: () -> Unit = EnsureNeverCalled(), + onRetry: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onRemoveMessage: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + ) { + setSafeContent { + SendFailureDialogView( + sendFailureDialogState = sendFailureDialogState, + onDismiss = onDismiss, + onRetry = onRetry, + onRemoveMessage = onRemoveMessage, + ) + } + } +} From 9aeb4c4a9f86d87159eda4e98f024e85b101acbf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:45:02 +0200 Subject: [PATCH 082/118] Update roborazzi to v1.72.0 (#7489) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97fe235b96..7a617d5ee9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.71.0" +roborazzi = "1.72.0" # Jetbrain datetime = "0.8.0" From babed8eb1f19cb0ce4d2daa28c8aaabb9d1aeaa3 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 20 Aug 2026 18:01:33 +0000 Subject: [PATCH 083/118] Update screenshots --- ...pl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_0_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_1_en.png | 3 +++ .../images/features.messages.impl_MessagesViewA11y_en.png | 4 ++-- 5 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png new file mode 100644 index 0000000000..701a10ceb5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65e30408e30c4a6d28900b60ec4b4b43fec5343dcd3487f53cafc8eb58e94a17 +size 21043 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png new file mode 100644 index 0000000000..f0b6a98753 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88e8f1e5adb178387d6a151cc56ed530ff15c9e4d6a7deebddd12ba1f8ea7e71 +size 19642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png index 78a758b1e8..3c357233b9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:996251b2e0e0a28ec167ec137f913ed5bb6f21b443bbf8151bc3203ad853b667 -size 131374 +oid sha256:95c5746de02c584536b1178614d15460bbdda633ddf44485261007618bd99f50 +size 132268 From 069763ce547418508e0a2d0893b79123f3a3e96a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:00:30 +0000 Subject: [PATCH 084/118] Update metro to v1.4.2 (#7493) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a617d5ee9..a46141a37d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ haze = "1.7.2" dependencyAnalysis = "3.18.0" # DI -metro = "1.4.1" +metro = "1.4.2" # Auto service autoservice = "1.1.1" From 25989135a8cc72caf80431e7d433d3cbec77d628 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 09:35:35 +0200 Subject: [PATCH 085/118] Add error content to Localazy --- .../impl/timeline/TimelinePresenter.kt | 12 ++++++---- .../sendfailure/SendFailureDialogState.kt | 11 ++++++++- .../SendFailureDialogStateProvider.kt | 18 ++++++++++++++- .../sendfailure/SendFailureDialogView.kt | 23 ++++++++++++++++++- .../impl/src/main/res/values/localazy.xml | 5 ++++ tools/localazy/config.json | 1 + 6 files changed, 63 insertions(+), 7 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index fb64e0c096..6d5e0b2bf1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -258,25 +258,29 @@ class TimelinePresenter( is LocalEventSendState.Failed.InvalidMimeType -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = "Invalid mime type: ${sendStateFailure.mimeType}", + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType), ) } LocalEventSendState.Failed.MissingMediaContent -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = "Missing media content", + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, ) } LocalEventSendState.Failed.SendingFromUnverifiedDevice -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = "Sending from unverified device", + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, ) } is LocalEventSendState.Failed.Unknown -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = sendStateFailure.error.takeIf { it.isNotBlank() } ?: "Unknown error", + sendFailureType = if (sendStateFailure.error.isNotBlank()) { + SendFailureDialogState.SendFailureType.Error(sendStateFailure.error) + } else { + SendFailureDialogState.SendFailureType.Unknown + } ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt index c3d3b2d9b0..02da5a6503 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt @@ -16,6 +16,15 @@ sealed interface SendFailureDialogState { data class Show( val event: TimelineItem.Event, - val message: String, + val sendFailureType: SendFailureType, ) : SendFailureDialogState + + @Immutable + sealed interface SendFailureType { + data class InvalidMimeType(val mimeType: String) : SendFailureType + data object MissingMediaContent : SendFailureType + data object SendingFromUnverifiedDevice : SendFailureType + data class Error(val message: String) : SendFailureType + data object Unknown : SendFailureType + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt index b6730f0392..55c894c0d9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt @@ -16,7 +16,23 @@ class SendFailureDialogStateProvider : PreviewParameterProvider Unit is SendFailureDialogState.Show -> { // Show the dialog with the message and actions + val content = when (sendFailureDialogState.sendFailureType) { + is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource( + id = R.string.error_sending_failure_invalid_mime_type, + sendFailureDialogState.sendFailureType.mimeType, + ) + is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource( + id = R.string.error_sending_failure_missing_media_content, + ) + is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource( + id = R.string.error_sending_failure_sending_from_unverified_device, + ) + is SendFailureDialogState.SendFailureType.Error -> stringResource( + id = R.string.error_sending_failure_other, + sendFailureDialogState.sendFailureType.message + ) + is SendFailureDialogState.SendFailureType.Unknown -> stringResource( + id = R.string.error_sending_failure_unknown, + ) + } + ConfirmationDialog( modifier = modifier, title = stringResource(id = CommonStrings.common_sending_failed), - content = sendFailureDialogState.message, + content = content, onDismiss = onDismiss, submitText = stringResource(id = CommonStrings.action_retry), onSubmitClick = { onRetry(sendFailureDialogState.event) }, diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 556ab00ee0..e29b83af41 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -7,6 +7,11 @@ "Encrypted by an unknown or deleted device." "Encrypted by a device not verified by its owner." "Encrypted by an unverified user." + "Invalid mime type: %s" + "Missing media content" + "Error details: %s" + "Sending from unverified device" + "Unknown error" "Flip image horizontally" "Flipped horizontally" "Original" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 8a7790be1e..dddb21a6cf 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -271,6 +271,7 @@ "screen_room_retry.*", "screen_room_timeline.*", "screen\\.room\\.timeline.*", + "error\\.sending_failure\\..*", "screen\\.room_timeline.*", "screen_room_typing.*", "screen\\.image_edition\\..*", From eb37c7bd55d967d2edccc439d8b5f3c2861ddc65 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 09:49:23 +0200 Subject: [PATCH 086/118] Update test with Claude --- .../messages/impl/MessagesViewTest.kt | 15 ++++- .../impl/timeline/TimelinePresenterTest.kt | 27 +++++---- .../sendfailure/SendFailureDialogViewTest.kt | 58 ++++++++++++++++--- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 2a6353de87..677b2ac10b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -568,7 +568,10 @@ class MessagesViewTest : RobolectricTest() { setMessagesView( state = aMessagesState( timelineState = aTimelineState( - sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), eventSink = timelineEventsRecorder, ), eventSink = messagesEventsRecorder, @@ -590,7 +593,10 @@ class MessagesViewTest : RobolectricTest() { setMessagesView( state = aMessagesState( timelineState = aTimelineState( - sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), eventSink = timelineEventsRecorder, ), eventSink = messagesEventsRecorder, @@ -612,7 +618,10 @@ class MessagesViewTest : RobolectricTest() { setMessagesView( state = aMessagesState( timelineState = aTimelineState( - sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), eventSink = timelineEventsRecorder, ), eventSink = messagesEventsRecorder, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3e4e8bc0ae..45ca98e378 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -1404,7 +1404,10 @@ class TimelinePresenterTest { initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) awaitItem().also { state -> assertThat(state.sendFailureDialogState).isEqualTo( - SendFailureDialogState.Show(event = event, message = "An error") + SendFailureDialogState.Show( + event = event, + sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"), + ) ) state.eventSink(TimelineEvent.HideSendFailureDialog) } @@ -1416,39 +1419,39 @@ class TimelinePresenterTest { @Test fun `present - show send failure dialog - unknown error with a blank reason`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.Unknown(error = ""), - expectedMessage = "Unknown error", + expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown, ) } @Test fun `present - show send failure dialog - invalid mime type`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"), - expectedMessage = "Invalid mime type: invalid/mimeType", + expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"), ) } @Test fun `present - show send failure dialog - missing media content`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.MissingMediaContent, - expectedMessage = "Missing media content", + expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, ) } @Test fun `present - show send failure dialog - sending from unverified device`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice, - expectedMessage = "Sending from unverified device", + expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, ) } - private fun assertSendFailureDialogMessage( + private fun assertSendFailureType( sendState: LocalEventSendState.Failed, - expectedMessage: String, + expectedSendFailureType: SendFailureDialogState.SendFailureType, ) = runTest { val presenter = createTimelinePresenter() val event = aMessageEvent(sendState = sendState) @@ -1456,7 +1459,7 @@ class TimelinePresenterTest { val initialState = awaitFirstItem() initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) assertThat(awaitItem().sendFailureDialogState).isEqualTo( - SendFailureDialogState.Show(event = event, message = expectedMessage) + SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType) ) cancelAndIgnoreRemainingEvents() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt index 204d367b0f..05388e7227 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam @@ -37,12 +39,54 @@ class SendFailureDialogViewTest : RobolectricTest() { } @Test - fun `show state renders the failure reason`() = runAndroidComposeUiTest { + fun `show state renders the error failure reason`() = runAndroidComposeUiTest { setSendFailureDialogView( - sendFailureDialogState = aSendFailureDialogStateShow(message = "A failure reason"), + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.Error("A failure reason"), + ), ) - onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertExists() - onNodeWithText("A failure reason").assertExists() + assertNodeWithTextIsDisplayed(CommonStrings.common_sending_failed) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_other, "A failure reason")).assertExists() + } + + @Test + fun `show state renders the invalid mime type failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"), + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_invalid_mime_type, "invalid/mimeType")).assertExists() + } + + @Test + fun `show state renders the missing media content failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_missing_media_content)).assertExists() + } + + @Test + fun `show state renders the sending from unverified device failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_sending_from_unverified_device)).assertExists() + } + + @Test + fun `show state renders the unknown failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.Unknown, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_unknown)).assertExists() } @Test @@ -81,10 +125,10 @@ class SendFailureDialogViewTest : RobolectricTest() { } private fun aSendFailureDialogStateShow( - message: String = "A failure reason", + sendFailureType: SendFailureDialogState.SendFailureType = SendFailureDialogState.SendFailureType.Unknown, ) = SendFailureDialogState.Show( - event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown(message)), - message = message, + event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("")), + sendFailureType = sendFailureType, ) private fun AndroidComposeUiTest.setSendFailureDialogView( From 392d73826a7e0720458099139e5167228697c5fd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 09:54:06 +0200 Subject: [PATCH 087/118] Add instruction about screenshot test, so that AI does not try to record them. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d206be98c6..2c6cc9d60c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ PRs must meet these rules. - Use `@PreviewsDayNight` for consistency. - Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states. - Wrap previews in `ElementPreview { ... }`. +- When writing tests, never try to record the screenshots, the CI will do it. --- From 4b32816b32bc9a4adcb29edf0bc3ac04badda114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 10:05:00 +0200 Subject: [PATCH 088/118] Add `ClientEnterpriseHook` and `ClientBuilderEnterpriseHook` These allow us to not expose the enterprise internal when setting up custom configs for both `ClientBuilder` and `Client` --- enterprise | 2 +- .../api/ClientBuilderEnterpriseHook.kt | 18 +++++++++++ .../enterprise/api/ClientEnterpriseHook.kt | 17 ++++++++++ .../RemoteEnterpriseConfigResult.kt | 6 ++-- .../DefaultClientBuilderEnterpriseHook.kt | 26 ++++++++++++++++ .../impl/DefaultClientEnterpriseHook.kt | 25 +++++++++++++++ .../DefaultContentScannerUrlProvider.kt | 29 ----------------- .../matrix/api/MatrixClientBuilder.kt | 13 ++++++++ .../api/scanner/ContentScannerUrlProvider.kt | 31 ------------------- libraries/matrix/impl/build.gradle.kts | 6 ++-- .../libraries/matrix/impl/RustMatrixClient.kt | 2 +- .../matrix/impl/RustMatrixClientBuilder.kt | 18 +++++++++++ .../matrix/impl/RustMatrixClientFactory.kt | 23 +++----------- .../auth/RustMatrixAuthenticationService.kt | 17 ++++++++++ .../impl/RustMatrixClientFactoryTest.kt | 4 +-- .../RustTemporaryMatrixClientFactoryTest.kt | 4 +-- .../RustMatrixAuthenticationServiceTest.kt | 1 + .../impl/fixtures/fakes/FakeFfiClient.kt | 7 +++++ 18 files changed, 155 insertions(+), 94 deletions(-) create mode 100644 features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt create mode 100644 features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt create mode 100644 features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt create mode 100644 features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt delete mode 100644 features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt delete mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt diff --git a/enterprise b/enterprise index c87b257dd8..eada114236 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit c87b257dd87fedc9ca6a4001d0318aae5a453284 +Subproject commit eada114236d21944ab1b36fc9adc7943d1b6fc3b diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt new file mode 100644 index 0000000000..6648cb08c9 --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt @@ -0,0 +1,18 @@ +/* + * 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.enterprise.api + +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features. + */ +fun interface ClientBuilderEnterpriseHook { + suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId) +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt new file mode 100644 index 0000000000..e6d5c9aac1 --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt @@ -0,0 +1,17 @@ +/* + * 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.enterprise.api + +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * A hook that can be used to customize the [MatrixClient] for enterprise features. + */ +fun interface ClientEnterpriseHook { + suspend operator fun invoke(client: MatrixClient) +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt index ace0bada0b..0cdcdebe0b 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt @@ -19,11 +19,9 @@ sealed interface RemoteEnterpriseConfigResult { data class Outdated(val data: T) : RemoteEnterpriseConfigResult /** - * Well-known data is not found (file does not exist server side, we got a 404). `needsRefresh` indicates whether a refresh must be done - * to replace this value: if it's `true`, the caller must trigger a refresh to try to fetch the data again; - * if it's `false`, the caller can assume that the data is not available and no refresh is needed before returning the data. + * Well-known data is not found (file does not exist server side, we got a 404). */ - data class NotFound(val needsRefresh: Boolean) : RemoteEnterpriseConfigResult + data object NotFound : RemoteEnterpriseConfigResult /** * Any other error. diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt new file mode 100644 index 0000000000..37a9a24b9e --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { + override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId) { + // No-op + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt new file mode 100644 index 0000000000..3fd4e84587 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt @@ -0,0 +1,25 @@ +/* + * 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.enterprise.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.ClientEnterpriseHook +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * Default implementation of [ClientEnterpriseHook] that does nothing for FOSS. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultClientEnterpriseHook : ClientEnterpriseHook { + override suspend fun invoke(client: MatrixClient) { + // No-op + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt deleted file mode 100644 index 378e4570d9..0000000000 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt +++ /dev/null @@ -1,29 +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.enterprise.impl.scanner - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.matrix.api.ClientUrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider - -/** - * Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL. - */ -@ContributesBinding(AppScope::class) -class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { - override suspend fun getContentScannerUrl(sessionId: SessionId): Result = Result.success(null) - - @ContributesBinding(AppScope::class) - class Factory : ContentScannerUrlProvider.Factory { - override fun create(urlContentFetcher: ClientUrlContentFetcher): ContentScannerUrlProvider { - return DefaultContentScannerUrlProvider() - } - } -} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt new file mode 100644 index 0000000000..730561bbef --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +/** + * An abstraction over the Matrix SDK's `ClientBuilder` to allow sharing it with other modules without exposing the SDK directly. + */ +interface MatrixClientBuilder diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt deleted file mode 100644 index 0ed3f8f2a2..0000000000 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt +++ /dev/null @@ -1,31 +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.libraries.matrix.api.scanner - -import io.element.android.libraries.matrix.api.ClientUrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId - -/** - * Provides the URL of the content scanner service for a given homeserver, if any is set up. - */ -fun interface ContentScannerUrlProvider { - /** - * Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up. - */ - suspend fun getContentScannerUrl(sessionId: SessionId): Result - - /** - * Creates a provider, which needs a fetcher because the scanner URL is itself discovered over HTTP. - */ - fun interface Factory { - /** - * @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works. - */ - fun create(urlContentFetcher: ClientUrlContentFetcher): ContentScannerUrlProvider - } -} diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 2499b720e3..a736887528 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -21,12 +21,12 @@ android { setupDependencyInjection() dependencies { - releaseImplementation(libs.matrix.sdk) + releaseApi(libs.matrix.sdk) if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { println("\nNote: Using local binary of the Rust SDK.\n") - debugImplementation(projects.libraries.rustsdk) + debugApi(projects.libraries.rustsdk) } else { - debugImplementation(libs.matrix.sdk) + debugApi(libs.matrix.sdk) } implementation(projects.libraries.rustlsTls) 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 4be384d923..a36184d6bd 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 @@ -146,7 +146,7 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService @Suppress("LargeClass") class RustMatrixClient( override val sessionPaths: SessionPaths, - private val innerClient: Client, + val innerClient: Client, private val sessionStore: SessionStore, private val sessionDelegate: RustClientSessionDelegate, private val innerSyncService: ClientSyncService, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt new file mode 100644 index 0000000000..b2f952f35b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import org.matrix.rustcomponents.sdk.ClientBuilder + +/** + * A wrapper around the Matrix SDK's [ClientBuilder] to allow sharing it with other modules without exposing the SDK directly. + */ +class RustMatrixClientBuilder( + val inner: ClientBuilder, +) : MatrixClientBuilder diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index a0b06a2e1e..77ccead192 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl import dev.zacsweers.metro.Inject +import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook import io.element.android.libraries.androidutils.crypto.ClientSecret import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.ByteUnit @@ -19,7 +20,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.paths.SessionPaths -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.proxy.ProxyProvider @@ -37,7 +37,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder -import org.matrix.rustcomponents.sdk.ContentScanner import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.Session @@ -70,7 +69,7 @@ class RustMatrixClientFactory( private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, private val workManagerScheduler: WorkManagerScheduler, - private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory, + private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook, ) { private val sessionDelegate = RustClientSessionDelegate( sessionStore = sessionStore, @@ -101,6 +100,7 @@ class RustMatrixClientFactory( ) .homeserverUrl(sessionData.homeserverUrl) .username(sessionData.userId) + .also { clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) } .use { it.build() } client.setMediaRetentionPolicy( @@ -135,21 +135,6 @@ class RustMatrixClientFactory( client.setUtdDelegate(UtdTracker(analyticsService)) - // If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client. - // This allows the SDK to use the content scanner for automatic media scanning. - // If no content scanner URL is available, the contentScanner will be null. - val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null)) - val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId())) - .getOrNull() - ?.let { contentScannerUrl -> - val contentScanner = ContentScanner(contentScannerUrl) - client.setContentScanner(contentScanner) - RustContentScanner( - client = client, - rustScanner = contentScanner, - ) - } - val syncService = client.syncService() .withSharePos(true) .withOfflineMode() @@ -170,7 +155,7 @@ class RustMatrixClientFactory( featureFlagService = featureFlagService, analyticsService = analyticsService, workManagerScheduler = workManagerScheduler, - contentScanner = contentScanner, + contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) }, isMessageSearchAvailable = isMessageSearchAvailable, ).also { Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 153aa6d1d4..a1dacbee91 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.auth import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.ClientEnterpriseHook import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.androidutils.crypto.ClientSecret import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -73,6 +74,7 @@ class RustMatrixAuthenticationService( private val oAuthConfigurationProvider: OAuthConfigurationProvider, private val enterpriseService: EnterpriseService, private val featureFlagService: FeatureFlagService, + private val clientEnterpriseHook: ClientEnterpriseHook, ) : MatrixAuthenticationService { // Any existing Element Classic session that we want to try to import secrets from during login. private var elementClassicSession: ElementClassicSession? = null @@ -166,6 +168,10 @@ class RustMatrixAuthenticationService( sessionPaths = currentSessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) @@ -242,6 +248,9 @@ class RustMatrixAuthenticationService( client.restoreSession(sessionData.toSession()) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + // We wait for the verification state to be known matrixClient.waitForKnownVerificationState() @@ -332,6 +341,10 @@ class RustMatrixAuthenticationService( sessionPaths = currentSessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + matrixClient.waitForKnownVerificationState() newMatrixClientObservers.forEach { it.invoke(matrixClient) } @@ -397,6 +410,10 @@ class RustMatrixAuthenticationService( sessionPaths = emptySessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) 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 5e5000aedc..09e7236e74 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 @@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider @@ -52,7 +51,6 @@ fun TestScope.createRustMatrixClientFactory( ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -67,5 +65,5 @@ fun TestScope.createRustMatrixClientFactory( clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, + clientBuilderEnterpriseHook = { _, _ -> }, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index a8843a9ca2..968451c02d 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory @@ -64,7 +63,6 @@ class RustTemporaryMatrixClientFactoryTest { ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -79,6 +77,6 @@ class RustTemporaryMatrixClientFactoryTest { clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, + clientBuilderEnterpriseHook = { _, _ -> }, ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index e349c616c0..5d66e4f4bd 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -98,6 +98,7 @@ class RustMatrixAuthenticationServiceTest { ), enterpriseService = enterpriseService, featureFlagService = FakeFeatureFlagService(), + clientEnterpriseHook = {}, ) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index e91f8bdbed..86587da6c1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile +import io.element.android.libraries.matrix.impl.scanner.FakeFfiContentScanner import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_SERVER_NAME @@ -18,6 +19,7 @@ import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.ContentScanner import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.HomeserverCapabilities @@ -64,6 +66,7 @@ class FakeFfiClient( private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, private val getUrlResult: (String) -> ByteArray = { lambdaError() }, + private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -145,5 +148,9 @@ class FakeFfiClient( getUrlResult(url) } + override suspend fun contentScanner(): ContentScanner = simulateLongTask { + contentScannerResult() + } + override fun close() = closeResult() } From df2ea586bddbff4e3327c5e3a2b4ec3a46b6eb04 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 21 Aug 2026 08:13:54 +0000 Subject: [PATCH 089/118] Update screenshots --- ...pl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png | 4 ++-- ...pl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_1_en.png | 4 ++-- ....timeline.sendfailure_SendFailureDialogView_Night_2_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_3_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_4_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_5_en.png | 3 +++ 10 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png index 701a10ceb5..98a271f16e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65e30408e30c4a6d28900b60ec4b4b43fec5343dcd3487f53cafc8eb58e94a17 -size 21043 +oid sha256:3412f2c40e8bff9f152ad856050415a16b76376d46eda582168f75e126485676 +size 21803 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png new file mode 100644 index 0000000000..de396af500 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec927c9bd1b1e0000521d476f3141a6d2912b558ee1fddd089936afd59ac4d27 +size 20905 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png new file mode 100644 index 0000000000..3edc968290 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:346138ce103462468652d51f969514a17b096795163ce1d2be2a99adf61471f4 +size 21766 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png new file mode 100644 index 0000000000..74989de70a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1afe321e7c1f2a57d3317e952975655f89a4acae1386b425be089e03cc03a8d9 +size 22697 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png new file mode 100644 index 0000000000..7bd3c0d04f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aba767b7b1380ca1fcd49498376128b5e537da7f15b2512c6f6cf409b3a3f9df +size 19567 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png index f0b6a98753..b1f589b037 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88e8f1e5adb178387d6a151cc56ed530ff15c9e4d6a7deebddd12ba1f8ea7e71 -size 19642 +oid sha256:05211670ecfed13426dae281438c0f51d4d98b1d33178ac1e3753bc36855b952 +size 20362 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png new file mode 100644 index 0000000000..c2ff59b834 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9417e640c72190934981c3d7f29e9b60b241c69bcab800af7908b6b5d097089a +size 19453 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png new file mode 100644 index 0000000000..7aa92967f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e38f946dfda1008370b1171f4daffb2afea93b6f8ba9d8e816b8a12124467087 +size 20365 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png new file mode 100644 index 0000000000..c5e55379a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3996b007ecb0d6ccbba7b21d35a089b5ea5f0a314fe8a09b9f88e3829272a715 +size 21290 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png new file mode 100644 index 0000000000..5270ee0b32 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d7b95deec3bb0c5b542885a8900144233434561b5488e50c1560cdb287b9a61 +size 18154 From a59997336de0f801ee13c71398c79829fcc5b779 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 10:17:45 +0200 Subject: [PATCH 090/118] Suppress Large Class warning on test. --- .../element/android/features/messages/impl/MessagesViewTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 677b2ac10b..f662da0eb0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -90,6 +90,7 @@ import org.junit.Test import org.robolectric.annotation.Config import kotlin.time.Duration.Companion.milliseconds +@Suppress("LargeClass") class MessagesViewTest : RobolectricTest() { @Test fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest { From 7a3074af2078d799316513bfc9e32fca3b864e08 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:46:11 +0000 Subject: [PATCH 091/118] Allow empty description if GitHub issue is not empty --- .../features/rageshake/impl/bugreport/BugReportPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt index 85731ae959..c7c706fa68 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt @@ -86,7 +86,7 @@ class BugReportPresenter( fun handleEvent(event: BugReportEvents) { when (event) { BugReportEvents.SendBugReport -> { - if (formState.value.description.length < 10) { + if (formState.value.description.length < 10 && formState.value.ghIssueNumber == null) { sendingAction.value = AsyncAction.Failure(BugReportFormError.DescriptionTooShort) } else { sendingAction.value = AsyncAction.Loading From 614619e306db56487720691d8672761c683b3696 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 21 Aug 2026 11:47:49 +0200 Subject: [PATCH 092/118] Make PIP react to camera orientation changes in the other participant's device (#6951) * Make PIP react to camera orientation changes in the other participant's device Adds a `controls.onPipMediaOrientationUpdate` callback for the WebView that will trigger a new `pipParamsBuilder.setAspectRatioFromOrientation(orientation)` method * Fix warning about `PictureInPictureParams.Builder.setAspectRatio` only being available on API 26+ --- .../call/impl/pip/PictureInPictureEvent.kt | 2 ++ .../impl/pip/PictureInPicturePresenter.kt | 7 +++++ .../android/features/call/impl/pip/PipView.kt | 1 + .../features/call/impl/ui/CallScreenView.kt | 8 ++++- .../call/impl/ui/ElementCallActivity.kt | 16 ++++++++-- .../features/call/impl/utils/PipController.kt | 1 + .../call/impl/utils/WebViewAudioManager.kt | 2 +- .../call/impl/utils/WebViewPipController.kt | 30 +++++++++++++++++++ .../call/impl/pip/FakePipController.kt | 3 ++ .../features/call/impl/pip/FakePipView.kt | 4 ++- .../media/PictureInPictureParamsExt.kt | 29 ++++++++++++++++++ 11 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt index 1af13e148e..2053ff5102 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt @@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip import io.element.android.features.call.impl.utils.PipController sealed interface PictureInPictureEvent { + data object OnCallStarted : PictureInPictureEvent data class SetPipController(val pipController: PipController) : PictureInPictureEvent data object EnterPictureInPicture : PictureInPictureEvent data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent + data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt index 2dc16d7f26..aeb507f6ec 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt @@ -38,6 +38,9 @@ class PictureInPicturePresenter( fun handleEvent(event: PictureInPictureEvent) { when (event) { + is PictureInPictureEvent.OnCallStarted -> { + pipController?.setupPipCallbacks() + } is PictureInPictureEvent.SetPipController -> { pipController = event.pipController } @@ -55,6 +58,10 @@ class PictureInPicturePresenter( pipController?.exitPip() } } + is PictureInPictureEvent.SetPipOrientation -> { + Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}") + pipView?.setPipOrientation(event.orientation) + } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt index 90f74a37fd..aa1ab79dd0 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt @@ -9,6 +9,7 @@ package io.element.android.features.call.impl.pip interface PipView { + fun setPipOrientation(orientation: Int?) fun setPipParams() fun enterPipMode(): Boolean fun hangUp() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt index 2537eb739c..ee63e273e4 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -118,6 +118,7 @@ internal fun CallScreenView( if (webViewAudioManager?.isInCallMode?.get() == false) { Timber.d("URL $url is loaded, starting in-call audio mode") webViewAudioManager?.onCallStarted() + pipState.eventSink(PictureInPictureEvent.OnCallStarted) } else { Timber.d("Can't start in-call audio mode since the app is already in it.") } @@ -130,7 +131,12 @@ internal fun CallScreenView( onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, ) state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor)) - val pipController = WebViewPipController(webView) + val pipController = WebViewPipController( + webView = webView, + updatePipOrientation = { orientation -> + pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation)) + } + ) pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) }, onDestroyWebView = { diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index 9a093ff5a6..5ed9e29fe2 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui import android.Manifest import android.app.PictureInPictureParams import android.content.Intent +import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.util.Rational import android.view.WindowManager import android.webkit.PermissionRequest import androidx.activity.compose.setContent @@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger +import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.bindings import io.element.android.libraries.audio.api.AudioFocus @@ -86,6 +87,8 @@ class ElementCallActivity : private var eventSink: ((CallScreenEvent) -> Unit)? = null + private var currentPipOrientation: Int? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -199,6 +202,7 @@ class ElementCallActivity : } DisposableEffect(Unit) { val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> + setPipParams() pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode)) if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call") @@ -296,11 +300,17 @@ class ElementCallActivity : } } + override fun setPipOrientation(orientation: Int?) { + currentPipOrientation = orientation + setPictureInPictureParams(getPictureInPictureParams()) + } + @RequiresApi(Build.VERSION_CODES.O) private fun getPictureInPictureParams(): PictureInPictureParams { + // Portrait for calls seems more appropriate as a fallback value + val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT return PictureInPictureParams.Builder() - // Portrait for calls seems more appropriate - .setAspectRatio(Rational(3, 5)) + .setAspectRatioFromOrientation(orientation) .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setAutoEnterEnabled(true) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt index b259816f9d..f7788ad520 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt @@ -9,6 +9,7 @@ package io.element.android.features.call.impl.utils interface PipController { + fun setupPipCallbacks() suspend fun canEnterPip(): Boolean fun enterPip() fun exitPip() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt index e68e57118b..3290269971 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -250,7 +250,7 @@ class WebViewAudioManager( hasRegisteredCallbacks = true } - } + }, ) Timber.d("Setting androidNativeBridge javascript interface in webview") webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge") diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt index 12f8dfd00f..88648e84fa 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt @@ -8,7 +8,10 @@ package io.element.android.features.call.impl.utils +import android.content.res.Configuration +import android.webkit.JavascriptInterface import android.webkit.WebView +import timber.log.Timber import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine */ class WebViewPipController( private val webView: WebView, + updatePipOrientation: (orientation: Int?) -> Unit, ) : PipController { + init { + // Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it + webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge") + } + + override fun setupPipCallbacks() { + Timber.d("Adding callback in controls.onPipMediaOrientationUpdate") + webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null) + } + override suspend fun canEnterPip(): Boolean { return suspendCoroutine { continuation -> webView.evaluateJavascript("controls.canEnterPip()") { result -> @@ -36,3 +50,19 @@ class WebViewPipController( webView.evaluateJavascript("controls.disablePip()", null) } } + +private class PipBridge( + private val onOrientationChange: (orientation: Int?) -> Unit, +) { + @JavascriptInterface + fun setPipOrientation(orientation: String?) { + Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation") + // This callback is used to update the picture-in-picture orientation in the WebView when it changes + val orientation = when (orientation) { + "portrait" -> Configuration.ORIENTATION_PORTRAIT + "landscape" -> Configuration.ORIENTATION_LANDSCAPE + else -> return + } + onOrientationChange(orientation) + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt index 5153f79c31..f2eca681e9 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt @@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController import io.element.android.tests.testutils.lambda.lambdaError class FakePipController( + private val setupPipCallbacksResult: () -> Unit = { lambdaError() }, private val canEnterPipResult: () -> Boolean = { lambdaError() }, private val enterPipResult: () -> Unit = { lambdaError() }, private val exitPipResult: () -> Unit = { lambdaError() }, ) : PipController { + override fun setupPipCallbacks() = setupPipCallbacksResult() + override suspend fun canEnterPip(): Boolean = canEnterPipResult() override fun enterPip() = enterPipResult() diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt index 55e94312bf..adfcde2c38 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt @@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError class FakePipView( private val setPipParamsResult: () -> Unit = { lambdaError() }, private val enterPipModeResult: () -> Boolean = { lambdaError() }, - private val handUpResult: () -> Unit = { lambdaError() } + private val handUpResult: () -> Unit = { lambdaError() }, + private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() }, ) : PipView { override fun setPipParams() = setPipParamsResult() override fun enterPipMode(): Boolean = enterPipModeResult() override fun hangUp() = handUpResult() + override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt new file mode 100644 index 0000000000..b17ec0260b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.media + +import android.app.PictureInPictureParams +import android.content.res.Configuration +import android.os.Build +import android.util.Rational +import androidx.annotation.RequiresApi + +/** + * Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device. + */ +@RequiresApi(Build.VERSION_CODES.O) +fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder { + val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + // If landscape orientation, invert the aspect ratio + Rational(5, 3) + } else { + // In any other case, assume portrait orientation + Rational(3, 5) + } + return setAspectRatio(aspectRatio) +} From 9a2d23c8a126a0df1f4e40a8401dbe2bdff125b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:07:38 +0200 Subject: [PATCH 093/118] Remove unnecessary `GlobalSearchPresenter.Factory` --- .../home/impl/roomlist/RoomListPresenter.kt | 8 ++------ .../home/impl/search/GlobalSearchPresenter.kt | 16 +++++----------- .../home/impl/roomlist/RoomListPresenterTest.kt | 9 +++------ .../impl/search/GlobalSearchPresenterTest.kt | 2 -- 4 files changed, 10 insertions(+), 25 deletions(-) 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 47b02cac08..1c21177988 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 @@ -30,7 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into -import io.element.android.features.home.impl.search.GlobalSearchPresenter +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -86,15 +86,11 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, - private val globalSearchPresenterFactory: GlobalSearchPresenter.Factory, + private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService - private val globalSearchPresenter by lazy { - globalSearchPresenterFactory.create(coroutineScope = client.sessionCoroutineScope) - } - @Composable override fun present(): RoomListState { val coroutineScope = rememberCoroutineScope() diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 11de6e7c80..0014d70ff8 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -15,16 +15,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -48,7 +48,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.isMediaConten import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.messages.toPlainText import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay @@ -58,9 +57,8 @@ import kotlinx.coroutines.launch import kotlin.jvm.optionals.getOrElse import kotlin.time.Duration.Companion.milliseconds -@AssistedInject +@ContributesBinding(SessionScope::class) class GlobalSearchPresenter( - @Assisted private val coroutineScope: CoroutineScope, private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, private val messageSearchService: MessageSearchService, private val featureFlagService: FeatureFlagService, @@ -71,13 +69,9 @@ class GlobalSearchPresenter( private val coroutineDispatchers: CoroutineDispatchers, private val matrixClient: MatrixClient, ) : Presenter { - @AssistedFactory - fun interface Factory { - fun create(coroutineScope: CoroutineScope): GlobalSearchPresenter - } - @Composable override fun present(): GlobalSearchState { + val coroutineScope = rememberCoroutineScope() val isEnabled by produceState(false) { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } } 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 8cfc47fe43..470aaaa441 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 @@ -683,12 +683,9 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, - globalSearchPresenterFactory = object : GlobalSearchPresenter.Factory { - override fun create(coroutineScope: CoroutineScope) = createGlobalSearchPresenter( - matrixClient = client, - coroutineScope = coroutineScope, - ) - }, + globalSearchPresenter = createGlobalSearchPresenter( + matrixClient = client, + ), filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 7469ea4e84..3f5fe802f9 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -211,10 +211,8 @@ internal fun TestScope.createGlobalSearchPresenter( fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), permalinkParser: PermalinkParser = FakePermalinkParser(), matrixClient: MatrixClient = FakeMatrixClient(), - coroutineScope: CoroutineScope = backgroundScope, ): GlobalSearchPresenter { return GlobalSearchPresenter( - coroutineScope = coroutineScope, roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource { return RoomListSearchDataSource( From 3a946475f73945ebb80cf7f487643b2e7bf9d445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:37:47 +0200 Subject: [PATCH 094/118] Fix search results never displaying an empty state Use the pagination state to understand when a search finished with no results --- .../home/impl/search/GlobalSearchPresenter.kt | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 0014d70ff8..aeee0ddfeb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -47,13 +47,16 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.messages.toPlainText +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import timber.log.Timber import kotlin.jvm.optionals.getOrElse import kotlin.time.Duration.Companion.milliseconds @@ -92,8 +95,11 @@ class GlobalSearchPresenter( AsyncData.Uninitialized } - roomListSearchDataSource.setSearchQuery(queryState.text.toString()) - currentMessageSearch.setQuery(queryState.text.toString()) + launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) } + launch { + currentMessageSearch.setQuery(queryState.text.toString()) + .onFailure { Timber.e(it, "Could not set query for message search") } + } } LaunchedEffect(currentTarget) { @@ -114,7 +120,15 @@ class GlobalSearchPresenter( } } GlobalSearchTarget.MESSAGES -> { - currentMessageSearch.results.collectLatest { results -> + Timber.d("Message search found ${currentMessageSearch.results.value.size} items") + combine( + currentMessageSearch.results, + currentMessageSearch.paginationState, + ) { results, paginationState -> + Pair(results, paginationState) + }.collectLatest { (results, paginationState) -> + Timber.d("Message search found ${results.size} items, pagination state: $paginationState") + // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. val mappedResults = results.map { result -> @@ -136,10 +150,19 @@ class GlobalSearchPresenter( .awaitAll() .mapNotNull { it } - searchResults = if (queryState.text.isNotEmpty() && mappedResults.isNotEmpty()) { - AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) - } else { - AsyncData.Uninitialized + searchResults = when { + mappedResults.isNotEmpty() -> { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) + } + paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) + } + paginationState is MessageSearchPaginationState.Loading -> { + AsyncData.Loading(prevData = searchResults.dataOrNull()) + } + else -> { + AsyncData.Uninitialized + } } } } From b29a4c06d715be764e7400cfaa78762723ac8ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:43:09 +0200 Subject: [PATCH 095/118] Improve previews --- .../home/impl/roomlist/RoomListStateProvider.kt | 5 ++++- .../home/impl/search/GlobalSearchStateProvider.kt | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt index 034e7acd42..f5f6d6a4a8 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -43,7 +43,10 @@ open class RoomListStateProvider : PreviewParameterProvider { aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), aRoomListState(contentState = anEmptyContentState()), aRoomListState(contentState = aSkeletonContentState()), - aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), + aRoomListState( + searchState = aRoomListSearchState(isSearchActive = true, query = "Test"), + globalSearchState = aGlobalSearchState(isEnabled = false), + ), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)), aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))), aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt index c4343b8982..29031837f2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList class GlobalSearchStateProvider : PreviewParameterProvider { override val values: Sequence @@ -56,7 +57,13 @@ class GlobalSearchStateProvider : PreviewParameterProvider { isSearchActive = true, currentTarget = GlobalSearchTarget.ROOMS, queryState = TextFieldState("Query"), - results = AsyncData.Success(GlobalSearchResults.RoomListResults(aRoomListRoomSummaryList())), + results = AsyncData.Success( + GlobalSearchResults.RoomListResults( + results = aRoomListRoomSummaryList().mapIndexed { index, summary -> + summary.copy(name = "Room with Query #${index + 1}") + }.toPersistentList() + ) + ), ), aGlobalSearchState( isSearchActive = true, @@ -65,7 +72,7 @@ class GlobalSearchStateProvider : PreviewParameterProvider { results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( MessageSearchResultItem.Message( messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), - body = "A message", + body = "A message with Query", roomInfo = aRoomInfo(), formattedTimestamp = "12:00", ), @@ -74,7 +81,7 @@ class GlobalSearchStateProvider : PreviewParameterProvider { mediaContent = MediaSearchResultContent( filename = "file.png", extension = "PNG", - caption = "A caption", + caption = "A caption containing Query", formattedSize = "1 MB", thumbnailSource = MediaSource("https://example.com/thumbnail.png"), thumbnailType = AttachmentThumbnailType.Image, From d05978cc16f33d6b701227b18c2c64f52b18e671 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 21 Aug 2026 11:00:17 +0000 Subject: [PATCH 096/118] Update screenshots --- .../features.home.impl.search_GlobalSearchView_Day_3_en.png | 4 ++-- .../features.home.impl.search_GlobalSearchView_Day_4_en.png | 4 ++-- .../features.home.impl.search_GlobalSearchView_Night_3_en.png | 4 ++-- .../features.home.impl.search_GlobalSearchView_Night_4_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_12_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_12_en.png | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png index 0a3484d59e..25a66543da 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1c9b463f6fa8fa8c1e9c8a776734879201f506f4cad37ecdba05baabe49b1b3 -size 26668 +oid sha256:18498dbff28491dd108900453eab13d8224aa7960eefbe25993dcc82198677dd +size 35825 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png index a8bf17e259..be7992706c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b58ab253d632a15dd5274351ec47dd293099a3fbf7dcac16103d8e63423b09b9 -size 73864 +oid sha256:346946f668bf18908480bf6b97158adcd7d9099d4be111055412ac2d4b7094c9 +size 77330 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png index aa0fd55433..8dc241988a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20701c8eca52f816c18c788f6ecef485255e3059b5790fc1705c52ede5d75566 -size 26708 +oid sha256:a7d01b051fda7a3f6dc7736bdbd631bd2e0f0cef61dbd9c973b48cee1e69d56c +size 35624 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png index 004b62009e..8e618f0070 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07388f4fed1ecfd51aa2c98c0014b08aee983b50e691edef95c9d290c2c948aa -size 72281 +oid sha256:6637c79e684bf350204756a93679163fb17b8cf5f54c835fe81464d644edc3ff +size 75840 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png index b5f80f2faf..11ffe943aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:7a1e3fcf68d17303f7082f2f0ee124a166778bad097a9e9e49e4a2de3c63c506 +size 5700 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png index fe171e26b7..f57f515cee 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:aa9cd96bd8827121d24abaa44055485e34d5f8f3ee24c845a46247a094ca5b83 +size 5566 From 76b6e5857f105916b1f905c0965428d4a6e7ac5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:57:33 +0200 Subject: [PATCH 097/118] Fix lint issues --- .../features/home/impl/roomlist/RoomListPresenterTest.kt | 2 -- 1 file changed, 2 deletions(-) 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 470aaaa441..e82fa70052 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 @@ -18,7 +18,6 @@ import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFact import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary -import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.search.aRoomListSearchState @@ -82,7 +81,6 @@ 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.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first From f28b5093df72501a269980fff621162ab5fdf3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 13:22:50 +0200 Subject: [PATCH 098/118] Fix licenses in enterprise --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index eada114236..845e3072aa 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit eada114236d21944ab1b36fc9adc7943d1b6fc3b +Subproject commit 845e3072aad4bc8444c1e5878597275993071f02 From e4891abf45cb59ac7a3062dcd7244983d327dbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 13:03:06 +0200 Subject: [PATCH 099/118] `GlobalSearchPresenter` doesn't need `@ContributesBinding` --- .../android/features/home/impl/roomlist/RoomListPresenter.kt | 4 ++-- .../features/home/impl/search/GlobalSearchPresenter.kt | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) 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 1c21177988..7b19fee8ef 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 @@ -30,7 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into -import io.element.android.features.home.impl.search.GlobalSearchState +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -86,7 +86,7 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, - private val globalSearchPresenter: Presenter, + private val globalSearchPresenter: GlobalSearchPresenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index aeee0ddfeb..b7ab71cee2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -17,14 +17,13 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -60,7 +59,7 @@ import timber.log.Timber import kotlin.jvm.optionals.getOrElse import kotlin.time.Duration.Companion.milliseconds -@ContributesBinding(SessionScope::class) +@Inject class GlobalSearchPresenter( private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, private val messageSearchService: MessageSearchService, From 679d97cf4f9ea818f6c9fa6874ba791044f84124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 15:15:48 +0200 Subject: [PATCH 100/118] Fix konsist issue about presenters not depending in other concrete presenter implementations --- .../element/android/features/home/impl/di/RoomListModule.kt | 4 ++++ .../features/home/impl/roomlist/RoomListPresenter.kt | 4 ++-- .../features/home/impl/search/RoomListSearchDataSource.kt | 2 ++ .../features/home/impl/roomlist/RoomListPresenterTest.kt | 6 ++---- .../features/home/impl/search/GlobalSearchPresenterTest.kt | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt index 1eeba4fff7..8993c19480 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt @@ -15,6 +15,7 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.roomlist.RoomListPresenter import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter @@ -36,4 +37,7 @@ interface RoomListModule { @Binds fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter + + @Binds + fun bindGlobalSearchPresenter(presenter: RoomListSearchPresenter): Presenter } 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 7b19fee8ef..1c21177988 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 @@ -30,7 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into -import io.element.android.features.home.impl.search.GlobalSearchPresenter +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -86,7 +86,7 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, - private val globalSearchPresenter: GlobalSearchPresenter, + private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt index 03fcb5520f..df7ed0dd37 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt @@ -46,6 +46,8 @@ class RoomListSearchDataSource( coroutineScope = coroutineScope ) + val loadingState = roomList.loadingState + val roomSummaries: Flow> = roomList.summaries .map { roomSummaries -> roomSummaries 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 e82fa70052..d2e33c77c1 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 @@ -20,8 +20,8 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aGlobalSearchState import io.element.android.features.home.impl.search.aRoomListSearchState -import io.element.android.features.home.impl.search.createGlobalSearchPresenter import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState import io.element.android.features.invite.api.SeenInvitesStore @@ -681,9 +681,7 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, - globalSearchPresenter = createGlobalSearchPresenter( - matrixClient = client, - ), + globalSearchPresenter = { aGlobalSearchState() }, filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 3f5fe802f9..cf28adb53b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -202,7 +202,7 @@ class GlobalSearchPresenterTest { } } -internal fun TestScope.createGlobalSearchPresenter( +private fun TestScope.createGlobalSearchPresenter( roomListService: RoomListService = FakeRoomListService(), messageSearchService: MessageSearchService = FakeMessageSearchService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), From c392737765e074a94a3ffe32a347360847eff8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 15:16:26 +0200 Subject: [PATCH 101/118] Try fixing the empty state for the room list search in global search --- .../home/impl/search/GlobalSearchPresenter.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index b7ab71cee2..103548daea 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult @@ -108,13 +109,25 @@ class GlobalSearchPresenter( when (currentTarget) { GlobalSearchTarget.ROOMS -> { - roomListSearchDataSource.roomSummaries.collectLatest { results -> + combine( + roomListSearchDataSource.roomSummaries, + roomListSearchDataSource.loadingState, + ) { summaries, loadingState -> + Pair(summaries, loadingState) + }.collectLatest { (results, loadingState) -> + Timber.d("Room list search found ${results.size} items, loading state: $loadingState") // The room list data source always returns some values, even if the query is empty, // so we only want to update the search results if the query is not empty - searchResults = if (queryState.text.isNotEmpty() && results.isNotEmpty()) { - AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) - } else { - AsyncData.Uninitialized + searchResults = when { + queryState.text.isNotEmpty() && results.isNotEmpty() -> { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } + loadingState is RoomList.LoadingState.Loaded && results.isEmpty() -> { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = persistentListOf())) + } + else -> { + AsyncData.Uninitialized + } } } } From feef42db5af0747f1b130c571a5bec3272451446 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 21 Aug 2026 15:23:34 +0200 Subject: [PATCH 102/118] Fix warning about deprecated Gradle `keepRules.file` API (#7498) * Fix warning about deprecated `keepRules.file` API The proguard files have been moved to `app/proguard` in several directories for `common`, `foss`, and `enterprise` rules and it's those directories that are added through `keepRules.addStaticSourceDirectory(dir)`. * Use `findPropery` instead of the deprecated `project.properties["key"]` API for `allWarningsAsErrors` * Remove very legacy .gitignore rule and restore proguard `.keep` rules --- .gitignore | 3 -- app/build.gradle.kts | 45 ++++++++++++------- .../common/common-proguard-rules.keep} | 0 .../foss/default-proguard-rules.keep} | 0 build.gradle.kts | 2 +- 5 files changed, 29 insertions(+), 21 deletions(-) rename app/{common-proguard-rules.pro => proguard/common/common-proguard-rules.keep} (100%) rename app/{default-proguard-rules.pro => proguard/foss/default-proguard-rules.keep} (100%) diff --git a/.gitignore b/.gitignore index 77acaadb20..a50ae12c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ __pycache__/ # Local configuration file (sdk path, etc) local.properties -# Proguard folder generated by Eclipse -proguard/ - # Log Files *.log diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f2c7d37baa..4293b3bc5f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,24 +127,11 @@ android { optimization { enable = true keepRules { - files.add(File(projectDir, "common-proguard-rules.pro")) - files.add(getDefaultProguardFile("proguard-android-optimize.txt")) - - // Depending on whether the app flavor is enterprise or not we want to use different proguard rules. - val flavorProguardFile = if (isEnterpriseBuild) { - // Custom rules for enterprise builds - File(projectDir, "enterprise-proguard-rules.pro") - } else { - // These default rules prevent the OSS app from being obfuscated - File(projectDir, "default-proguard-rules.pro") - } - - if (flavorProguardFile.exists()) { - files.add(flavorProguardFile) - } else { - logger.warn("Proguard file ${flavorProguardFile.absolutePath} does not exist") - } + // Equivalent of adding `getDefaultProguardFile("proguard-android-optimize.txt")` (this is the default value). + includeDefault = true } + // Our custom keep rules are registered as `keepRules` source folders in the `androidComponents` block below, + // as the former `keepRules.files` DSL is deprecated since AGP 9. } } @@ -229,6 +216,30 @@ androidComponents { ) onVariants { variant -> + // Register the R8 keep rules source folders for optimized build types (release, nightly). + // Replaces the deprecated `optimization.keepRules.files` DSL (AGP 9+). + if (variant.buildType != "debug") { + variant.sources.keepRules?.let { keepRules -> + // Common rules, always applied. + keepRules.addStaticSourceDirectory("proguard/common") + + // Depending on whether the app flavor is enterprise or not we want to use different proguard rules. + val flavorProguardDir = if (isEnterpriseBuild) { + // Custom rules for enterprise builds + "../enterprise/proguard" + } else { + // Custom fules for FOSS builds + "proguard/foss" + } + + if (File(projectDir, flavorProguardDir).exists()) { + keepRules.addStaticSourceDirectory(flavorProguardDir) + } else { + logger.warn("Proguard folder ${File(projectDir, flavorProguardDir).absolutePath} does not exist") + } + } + } + // Assigns a different version code for each output APK // other than the universal APK. variant.outputs.forEach { output -> diff --git a/app/common-proguard-rules.pro b/app/proguard/common/common-proguard-rules.keep similarity index 100% rename from app/common-proguard-rules.pro rename to app/proguard/common/common-proguard-rules.keep diff --git a/app/default-proguard-rules.pro b/app/proguard/foss/default-proguard-rules.keep similarity index 100% rename from app/default-proguard-rules.pro rename to app/proguard/foss/default-proguard-rules.keep diff --git a/build.gradle.kts b/build.gradle.kts index 0e0df9dbc6..6bbc568c66 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -98,7 +98,7 @@ allprojects { // This is disabled by default, but the CI will enforce this. // You can override by passing `-PallWarningsAsErrors=true` in the command line // Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file - allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true" + allWarningsAsErrors = findProperty("allWarningsAsErrors") == "true" // Uncomment to suppress Compose Kotlin compiler compatibility warning // freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) From 9935f91bc755776f0998d5524035455812a85a83 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 15:42:20 +0200 Subject: [PATCH 103/118] Fix wrong binding --- .../io/element/android/features/home/impl/di/RoomListModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt index 8993c19480..6b744ca4df 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt @@ -15,6 +15,7 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.roomlist.RoomListPresenter import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchState @@ -39,5 +40,5 @@ interface RoomListModule { fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter @Binds - fun bindGlobalSearchPresenter(presenter: RoomListSearchPresenter): Presenter + fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter } From 0a624c155009d7be00456c95f65fefbff2c7a0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 15:51:14 +0200 Subject: [PATCH 104/118] Fix empty state for room search --- .../home/impl/search/GlobalSearchPresenter.kt | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 103548daea..f0f3cac4d5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -31,7 +31,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.roomlist.LatestEventValue -import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult @@ -109,25 +108,14 @@ class GlobalSearchPresenter( when (currentTarget) { GlobalSearchTarget.ROOMS -> { - combine( - roomListSearchDataSource.roomSummaries, - roomListSearchDataSource.loadingState, - ) { summaries, loadingState -> - Pair(summaries, loadingState) - }.collectLatest { (results, loadingState) -> - Timber.d("Room list search found ${results.size} items, loading state: $loadingState") + roomListSearchDataSource.roomSummaries.collectLatest { results -> + Timber.d("Room list search found ${results.size} items") // The room list data source always returns some values, even if the query is empty, // so we only want to update the search results if the query is not empty - searchResults = when { - queryState.text.isNotEmpty() && results.isNotEmpty() -> { - AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) - } - loadingState is RoomList.LoadingState.Loaded && results.isEmpty() -> { - AsyncData.Success(GlobalSearchResults.RoomListResults(results = persistentListOf())) - } - else -> { - AsyncData.Uninitialized - } + searchResults = if (queryState.text.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } else { + AsyncData.Uninitialized } } } From ac7b42db18efed68eab8d0214bdd3913292f4899 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 16:07:51 +0200 Subject: [PATCH 105/118] Update Enterprise Copyright in idea settings. --- .idea/copyright/Element_Enterprise.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.idea/copyright/Element_Enterprise.xml b/.idea/copyright/Element_Enterprise.xml index b556049368..f7637c76a5 100644 --- a/.idea/copyright/Element_Enterprise.xml +++ b/.idea/copyright/Element_Enterprise.xml @@ -1,6 +1,6 @@ - \ No newline at end of file From 511ddb453ea6eaf474c88e37d840c3177d36cccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 16:08:06 +0200 Subject: [PATCH 106/118] Bump enterprise again --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index 845e3072aa..444de7a7d1 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 845e3072aad4bc8444c1e5878597275993071f02 +Subproject commit 444de7a7d156a73addc000964de1dab6df841c1c From e3b72446cc0c6a86f6c9f2dd130ae9191ad9dabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 16:10:44 +0200 Subject: [PATCH 107/118] Fix displaying the 'ready to search' state for message search after deleting the query --- .../android/features/home/impl/search/GlobalSearchPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index f0f3cac4d5..24b2d2fb87 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -154,7 +154,7 @@ class GlobalSearchPresenter( mappedResults.isNotEmpty() -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) } - paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + queryState.text.isNotEmpty() && paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) } paginationState is MessageSearchPaginationState.Loading -> { From b52ed580b134771678be365e420fd337adcf231a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 16:19:36 +0200 Subject: [PATCH 108/118] Add more tests --- .../home/impl/search/GlobalSearchPresenter.kt | 3 +- .../impl/search/GlobalSearchPresenterTest.kt | 72 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 24b2d2fb87..6e153344c9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -151,10 +151,11 @@ class GlobalSearchPresenter( .mapNotNull { it } searchResults = when { + queryState.text.isEmpty() -> AsyncData.Uninitialized mappedResults.isNotEmpty() -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) } - queryState.text.isNotEmpty() && paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) } paginationState is MessageSearchPaginationState.Loading -> { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index cf28adb53b..ef8d373630 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -139,13 +139,48 @@ class GlobalSearchPresenterTest { testScheduler.advanceUntilIdle() roomList.summaries.emit(listOf(aRoomSummary())) - val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + // Wait for the presenter to emit a loading state before the success state + consumeItemsUntilPredicate { it.results is AsyncData.Loading } + + val successState = awaitItem() val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results assertThat(results).hasSize(1) cancelAndIgnoreRemainingEvents() } } + @Test + fun `present - room search displays the uninitialized state when removing the query`() = runTest { + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(createRoomListLambda = { roomList }) + val presenter = createGlobalSearchPresenter(roomListService = roomListService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized) + + initialState.queryState.edit { append("A query") } + // Let the query be propagated to the data source before emitting results + testScheduler.advanceUntilIdle() + roomList.summaries.emit(listOf(aRoomSummary())) + + // Wait for the presenter to emit a loading state before the success state + consumeItemsUntilPredicate { it.results is AsyncData.Loading } + + val successState = awaitItem() + val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results + assertThat(results).hasSize(1) + + // Remove the query + successState.queryState.edit { replace(0, length, "") } + // Skip the intermediate state where the query is empty but results are still present + skipItems(1) + // It's back to the uninitialized state + assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java) + // This happens even if for some reason the room results still contained any items + assertThat(roomList.summaries.value).isNotEmpty() + } + } + @Test fun `present - message search results are mapped when the message search emits`() = runTest { val messageSearch = FakeMessageSearch() @@ -173,6 +208,41 @@ class GlobalSearchPresenterTest { } } + @Test + fun `present - message search displays the uninitialized state after removing the query`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + testScheduler.advanceUntilIdle() + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results + assertThat(results).hasSize(1) + assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + + // Remove the query + successState.queryState.edit { replace(0, length, "") } + // Skip the intermediate state where the query is empty but results are still present + skipItems(1) + // It's back to the uninitialized state + assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java) + // This happens even if for some reason the message results still contained any items + assertThat(messageSearch.results.value).isNotEmpty() + } + } + @Test fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { val messageSearch = FakeMessageSearch() From c56bb66307029d15c501f3cfc142198677aa392c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 17:32:45 +0200 Subject: [PATCH 109/118] Fix `ClientBuilderEnterpriseHook` not returning an updated `ClientBuilder` --- enterprise | 2 +- .../features/enterprise/api/ClientBuilderEnterpriseHook.kt | 2 +- .../android/libraries/matrix/impl/RustMatrixClientFactory.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise b/enterprise index 444de7a7d1..c7d77fd9a1 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 444de7a7d156a73addc000964de1dab6df841c1c +Subproject commit c7d77fd9a1f7ce053d1115cc8e233df1fdc99a13 diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt index 6648cb08c9..bfb9fb538a 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt @@ -14,5 +14,5 @@ import io.element.android.libraries.matrix.api.core.SessionId * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features. */ fun interface ClientBuilderEnterpriseHook { - suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId) + suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 77ccead192..0d593626bb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -100,7 +100,7 @@ class RustMatrixClientFactory( ) .homeserverUrl(sessionData.homeserverUrl) .username(sessionData.userId) - .also { clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) } + .let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } .use { it.build() } client.setMediaRetentionPolicy( From 3d9deed6e2ea45404afd333521edb5936aa46e04 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 17:38:41 +0200 Subject: [PATCH 110/118] Remove workaround for Renovate. It seems that it's not necessary anymore, thanks to https://github.com/renovatebot/renovate/pull/45350 --- .github/renovate.json5 | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c16310cf43..86ab2f1b1e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -7,7 +7,6 @@ "PR-Dependencies", ], "ignoreDeps": [ - "string:app_name", "gradle", ], "minimumReleaseAge": "7 days", From 27b58705f32c1f2864d8019974e359844691c15d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 17:51:18 +0200 Subject: [PATCH 111/118] Fix compilation issue. --- .../enterprise/impl/DefaultClientBuilderEnterpriseHook.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt index 37a9a24b9e..c4def13ed0 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt @@ -20,7 +20,8 @@ import io.element.android.libraries.matrix.api.core.SessionId @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { - override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId) { - // No-op + override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + // No modification + return clientBuilder } } From 2182840a71bc02aa194a89b03020f8beb8b288d8 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Wed, 19 Aug 2026 11:49:43 -0700 Subject: [PATCH 112/118] Let users enter their full Matrix user ID on the account provider screen --- .../ConfirmAccountProviderPresenter.kt | 18 ++++- .../ConfirmAccountProviderPresenterTest.kt | 72 +++++++++++++++++++ .../auth/FakeMatrixAuthenticationService.kt | 5 ++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 21bc9077dd..5c66f8f446 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -30,6 +30,7 @@ import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.preferences.api.store.AppPreferencesStore @AssistedInject @@ -87,6 +88,11 @@ class ConfirmAccountProviderPresenter( var submittedAccountProvider by remember { mutableStateOf(null) } val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider) + // A login hint (the full Matrix user ID) captured when the user enters one, so the authentication + // server can pre-fill their identity. Null when a plain account provider was entered. + var submittedLoginHint by remember { mutableStateOf(null) } + val latestSubmittedLoginHint by rememberUpdatedState(submittedLoginHint) + // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. // Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again. LaunchedEffect(changeServerState.changeServerAction) { @@ -97,7 +103,7 @@ class ConfirmAccountProviderPresenter( isAccountCreation = params.isAccountCreation, homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(), resolvedHomeserverUrl = null, - loginHint = null, + loginHint = latestSubmittedLoginHint, preConfiguredDetails = homeServerDetails, ) ) @@ -116,8 +122,16 @@ class ConfirmAccountProviderPresenter( // renders the full server rather than the typed prefix, and survives the OAuth round-trip. val accountProvider = event.accountProvider.trim() userInput = accountProvider + // If the user entered a full Matrix user ID (e.g. "@alice:example.org"), sign in to its + // homeserver and pass the ID as a login hint so the authentication server can pre-fill it. + // Otherwise treat the input as the account provider host. Only the homeserver is ever + // persisted to history, never the user ID. + val isUserId = MatrixPatterns.isUserId(accountProvider) + // For a user ID the homeserver is the domain part (after the ':'), keeping any port. + val host = if (isUserId) accountProvider.substringAfter(":") else accountProvider + submittedLoginHint = "mxid:$accountProvider".takeIf { isUserId && !params.isAccountCreation } // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works. - val accountProviderUrl = accountProvider.ensureProtocol() + val accountProviderUrl = host.ensureProtocol() submittedAccountProvider = accountProviderUrl changeServerState.eventSink( ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl)) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 1d9c32c0a0..641eea1563 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -460,6 +460,78 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - continue with a full matrix id signs in to its homeserver and passes a login hint`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://example.org") + assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a matrix id keeps the port in the homeserver`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org:8448")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://example.org:8448") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a plain account provider passes no login hint`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) }, + ) + val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(authenticationService.getOAuthUrlLoginHint).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a matrix id during account creation passes no login hint`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) }, + ) + val presenter = createConfirmAccountProviderPresenter( + params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(authenticationService.getOAuthUrlLoginHint).isNull() + cancelAndIgnoreRemainingEvents() + } + } + /** * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate * account-provider validation states, and returns that state. diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index 3b2fdeafb1..1eac6c0a1d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -70,10 +70,15 @@ class FakeMatrixAuthenticationService( return importCreatedSessionLambda(externalSession) } + /** The login hint passed to the most recent [getOAuthUrl] call. */ + var getOAuthUrlLoginHint: String? = null + private set + override suspend fun getOAuthUrl( prompt: OAuthPrompt, loginHint: String?, ): Result = simulateLongTask { + getOAuthUrlLoginHint = loginHint oAuthError?.let { Result.failure(it) } ?: Result.success(AN_OAUTH_DATA) } From b59afe7561e3646b0cc24a3dbb9bbe8ff466f884 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 18:11:26 +0200 Subject: [PATCH 113/118] No need for `@SingleIn` --- .../enterprise/impl/DefaultClientBuilderEnterpriseHook.kt | 2 -- .../features/enterprise/impl/DefaultClientEnterpriseHook.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt index c4def13ed0..ee22e4399d 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt @@ -9,7 +9,6 @@ package io.element.android.features.enterprise.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.SingleIn import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook import io.element.android.libraries.matrix.api.MatrixClientBuilder import io.element.android.libraries.matrix.api.core.SessionId @@ -17,7 +16,6 @@ import io.element.android.libraries.matrix.api.core.SessionId /** * Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS. */ -@SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt index 3fd4e84587..b0c0152d94 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt @@ -9,14 +9,12 @@ package io.element.android.features.enterprise.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.SingleIn import io.element.android.features.enterprise.api.ClientEnterpriseHook import io.element.android.libraries.matrix.api.MatrixClient /** * Default implementation of [ClientEnterpriseHook] that does nothing for FOSS. */ -@SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultClientEnterpriseHook : ClientEnterpriseHook { override suspend fun invoke(client: MatrixClient) { From e271a6b4a8dd118ab4bcc0a234a39301fe870442 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 18:13:44 +0200 Subject: [PATCH 114/118] Revert change on dependencies --- libraries/matrix/impl/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index a736887528..2499b720e3 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -21,12 +21,12 @@ android { setupDependencyInjection() dependencies { - releaseApi(libs.matrix.sdk) + releaseImplementation(libs.matrix.sdk) if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { println("\nNote: Using local binary of the Rust SDK.\n") - debugApi(projects.libraries.rustsdk) + debugImplementation(projects.libraries.rustsdk) } else { - debugApi(libs.matrix.sdk) + debugImplementation(libs.matrix.sdk) } implementation(projects.libraries.rustlsTls) From e205b7a3aa4576445d36589a9d05a7b63502bfed Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 18:14:07 +0200 Subject: [PATCH 115/118] Update ref. --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index c7d77fd9a1..e63a9cd299 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit c7d77fd9a1f7ce053d1115cc8e233df1fdc99a13 +Subproject commit e63a9cd299919755d497dc5a22d95da30d52f9ee From 277dd4a2fd5362d7f44f5816f155dc940f572333 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 18:53:43 +0200 Subject: [PATCH 116/118] Fix test compilation --- .../libraries/matrix/impl/RustMatrixClientFactoryTest.kt | 2 +- .../matrix/impl/RustTemporaryMatrixClientFactoryTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 09e7236e74..6d3afad5c3 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 @@ -65,5 +65,5 @@ fun TestScope.createRustMatrixClientFactory( clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - clientBuilderEnterpriseHook = { _, _ -> }, + clientBuilderEnterpriseHook = { builder, _ -> builder }, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index 968451c02d..d0b87ef53a 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -77,6 +77,6 @@ class RustTemporaryMatrixClientFactoryTest { clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - clientBuilderEnterpriseHook = { _, _ -> }, + clientBuilderEnterpriseHook = { builder, _ -> builder }, ) } From eb8574e042ba5af876bddbec3d5910febd9f4c56 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Sat, 22 Aug 2026 09:22:20 +0200 Subject: [PATCH 117/118] Remove dependency on shared module. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 1 - enterprise | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4293b3bc5f..f85d8dc33c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -270,7 +270,6 @@ dependencies { if (isEnterpriseBuild) { allEnterpriseImpl(project) implementation(projects.appicon.enterprise) - implementation(project(":enterprise:features:enterprise:shared")) } else { implementation(projects.features.enterprise.implFoss) implementation(projects.appicon.element) diff --git a/enterprise b/enterprise index e63a9cd299..ae0001a17d 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit e63a9cd299919755d497dc5a22d95da30d52f9ee +Subproject commit ae0001a17d503c9051704eeb6d7ff06609f87242 From 9e66572970684e4a8edfcef22d115e65e9fa3754 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:54:52 +0000 Subject: [PATCH 118/118] Update dependency com.squareup.okhttp3:okhttp-bom to v5.5.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a46141a37d..95242ad038 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -149,7 +149,7 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi squareup_seismic = "com.squareup:seismic:1.0.3" # network -network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.4.0" +network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.5.0" network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" } network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" } network_okhttp = { module = "com.squareup.okhttp3:okhttp" }