From 1929973209712349c087aa99b59f8804999278d0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 10 Apr 2026 12:53:53 +0200 Subject: [PATCH 01/53] Remove space announcement. Rework to keep the logic for fullscreen announcement. --- .../features/announcement/api/Announcement.kt | 9 +- .../announcement/impl/AnnouncementEvent.kt | 17 ++ .../impl/AnnouncementPresenter.kt | 40 +++- .../announcement/impl/AnnouncementState.kt | 11 +- .../impl/AnnouncementStateProvider.kt | 30 +++ .../impl/DefaultAnnouncementService.kt | 44 +--- .../impl/di/AnnouncementModule.kt | 29 --- .../fullscreen/FullscreenAnnouncementView.kt | 225 ++++++++++++++++++ .../impl/spaces/SpaceAnnouncementEvents.kt | 13 - .../impl/spaces/SpaceAnnouncementPresenter.kt | 40 ---- .../impl/spaces/SpaceAnnouncementState.kt | 13 - .../spaces/SpaceAnnouncementStateProvider.kt | 24 -- .../impl/spaces/SpaceAnnouncementView.kt | 158 ------------ .../impl/store/DefaultAnnouncementStore.kt | 6 +- .../impl/AnnouncementPresenterTest.kt | 14 +- .../impl/DefaultAnnouncementServiceTest.kt | 25 +- .../FullscreenAnnouncementViewTest.kt} | 36 +-- .../spaces/SpaceAnnouncementPresenterTest.kt | 41 ---- .../impl/store/InMemoryAnnouncementStore.kt | 8 +- .../features/home/impl/HomePresenter.kt | 8 +- .../features/home/impl/HomePresenterTest.kt | 13 - 21 files changed, 374 insertions(+), 430 deletions(-) create mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementEvent.kt create mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt delete mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt create mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt delete mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt delete mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt delete mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt delete mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt delete mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt rename features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/{spaces/SpaceAnnouncementViewTest.kt => fullscreen/FullscreenAnnouncementViewTest.kt} (51%) delete mode 100644 features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index 0bf35650a0..a83d167ee2 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -8,7 +8,10 @@ package io.element.android.features.announcement.api -enum class Announcement { - Space, - NewNotificationSound, +sealed interface Announcement { + sealed interface Fullscreen : Announcement { + data object Space : Fullscreen + } + + data object NewNotificationSound : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementEvent.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementEvent.kt new file mode 100644 index 0000000000..947a3ceeba --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementEvent.kt @@ -0,0 +1,17 @@ +/* + * 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.announcement.impl + +import io.element.android.features.announcement.api.Announcement + +sealed interface AnnouncementEvent { + data class Continue( + val announcement: Announcement.Fullscreen, + ) : AnnouncementEvent +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt index 508f1e44a0..bd45ddb956 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt @@ -12,12 +12,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch @Inject class AnnouncementPresenter( @@ -25,13 +29,39 @@ class AnnouncementPresenter( ) : Presenter { @Composable override fun present(): AnnouncementState { - val showSpaceAnnouncement by remember { - announcementStore.announcementStatusFlow(Announcement.Space).map { - it == AnnouncementStatus.Show + val coroutineScope = rememberCoroutineScope() + + val fullscreenAnnouncementToShow by remember { + combine( + flowOf(Unit), + announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).map { + it == AnnouncementStatus.Show + }, + // Add other announcements here when needed + ) { _, showFullscreenSpace -> + when { + showFullscreenSpace -> Announcement.Fullscreen.Space + else -> { + null + } + } } - }.collectAsState(false) + }.collectAsState(null) + + fun handle(event: AnnouncementEvent) { + when (event) { + is AnnouncementEvent.Continue -> coroutineScope.launch { + announcementStore.setAnnouncementStatus( + announcement = event.announcement, + status = AnnouncementStatus.Shown, + ) + } + } + } + return AnnouncementState( - showSpaceAnnouncement = showSpaceAnnouncement, + announcement = fullscreenAnnouncementToShow, + eventSink = ::handle, ) } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt index e762dd607f..3ef47d6ed0 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt @@ -8,12 +8,9 @@ package io.element.android.features.announcement.impl -data class AnnouncementState( - val showSpaceAnnouncement: Boolean, -) +import io.element.android.features.announcement.api.Announcement -fun anAnnouncementState( - showSpaceAnnouncement: Boolean = false, -) = AnnouncementState( - showSpaceAnnouncement = showSpaceAnnouncement, +data class AnnouncementState( + val announcement: Announcement.Fullscreen? = null, + val eventSink: (AnnouncementEvent) -> Unit, ) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt new file mode 100644 index 0000000000..2412fee167 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt @@ -0,0 +1,30 @@ +/* + * 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.announcement.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.announcement.api.Announcement + +open class AnnouncementStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAnnouncementState(), + anAnnouncementState( + announcement = Announcement.Fullscreen.Space, + ), + ) +} + +fun anAnnouncementState( + announcement: Announcement.Fullscreen? = null, + eventSink: (AnnouncementEvent) -> Unit = {}, +) = AnnouncementState( + announcement = announcement, + eventSink = eventSink, +) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index 0e5c30178c..adb81db61a 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -8,35 +8,28 @@ package io.element.android.features.announcement.impl -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService -import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState -import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView +import io.element.android.features.announcement.impl.fullscreen.FullscreenAnnouncementView import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore -import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) class DefaultAnnouncementService( private val announcementStore: AnnouncementStore, - private val announcementPresenter: Presenter, - private val spaceAnnouncementPresenter: Presenter, + private val announcementPresenter: AnnouncementPresenter, ) : AnnouncementService { override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { - Announcement.Space -> showSpaceAnnouncement() + is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) Announcement.NewNotificationSound -> { announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } @@ -49,13 +42,10 @@ class DefaultAnnouncementService( override fun announcementsToShowFlow(): Flow> { return combine( - announcementStore.announcementStatusFlow(Announcement.Space), + flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), - ) { spaceAnnouncementStatus, newNotificationSoundStatus -> + ) { _, newNotificationSoundStatus -> buildList { - if (spaceAnnouncementStatus == AnnouncementStatus.Show) { - add(Announcement.Space) - } if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } @@ -63,27 +53,19 @@ class DefaultAnnouncementService( } } - private suspend fun showSpaceAnnouncement() { - val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first() + private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { + val currentValue = announcementStore.announcementStatusFlow(announcement).first() if (currentValue == AnnouncementStatus.NeverShown) { - announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) + announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) } } @Composable override fun Render(modifier: Modifier) { val announcementState = announcementPresenter.present() - Box(modifier = modifier.fillMaxSize()) { - AnimatedVisibility( - visible = announcementState.showSpaceAnnouncement, - enter = fadeIn(), - exit = fadeOut(), - ) { - val spaceAnnouncementState = spaceAnnouncementPresenter.present() - SpaceAnnouncementView( - state = spaceAnnouncementState, - ) - } - } + FullscreenAnnouncementView( + state = announcementState, + modifier = modifier, + ) } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt deleted file mode 100644 index 4cfc073271..0000000000 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt +++ /dev/null @@ -1,29 +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.announcement.impl.di - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.BindingContainer -import dev.zacsweers.metro.Binds -import dev.zacsweers.metro.ContributesTo -import io.element.android.features.announcement.impl.AnnouncementPresenter -import io.element.android.features.announcement.impl.AnnouncementState -import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementPresenter -import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState -import io.element.android.libraries.architecture.Presenter - -@ContributesTo(AppScope::class) -@BindingContainer -interface AnnouncementModule { - @Binds - fun bindAnnouncementPresenter(presenter: AnnouncementPresenter): Presenter - - @Binds - fun bindSpaceAnnouncementPresenter(presenter: SpaceAnnouncementPresenter): Presenter -} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt new file mode 100644 index 0000000000..c544fd4914 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt @@ -0,0 +1,225 @@ +/* + * 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.announcement.impl.fullscreen + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.announcement.api.Announcement +import io.element.android.features.announcement.impl.AnnouncementEvent +import io.element.android.features.announcement.impl.AnnouncementState +import io.element.android.features.announcement.impl.AnnouncementStateProvider +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.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4593-40181 + */ +@Composable +fun FullscreenAnnouncementView( + state: AnnouncementState, + modifier: Modifier = Modifier, +) { + // Ensure that the content stays visible during the exit animation + var fullscreenAnnouncement by remember { mutableStateOf(null) } + if (state.announcement != null) { + fullscreenAnnouncement = state.announcement + } + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = state.announcement != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + fullscreenAnnouncement?.let { + FullscreenAnnouncementView( + announcement = it, + eventSink = state.eventSink, + ) + } + } + } +} + +@Composable +private fun FullscreenAnnouncementView( + announcement: Announcement.Fullscreen, + eventSink: (AnnouncementEvent) -> Unit, + modifier: Modifier = Modifier +) { + fun onContinue() { + eventSink(AnnouncementEvent.Continue(announcement)) + } + + BackHandler(onBack = ::onContinue) + HeaderFooterPage( + modifier = modifier, + isScrollable = true, + contentPadding = PaddingValues(top = 24.dp, start = 16.dp, end = 16.dp, bottom = 24.dp), + header = { + FullscreenAnnouncementHeader(announcement) + }, + content = { + FullscreenAnnouncementContent( + modifier = Modifier.padding(horizontal = 8.dp), + announcement = announcement, + ) + }, + footer = { + FullscreenAnnouncementFooter( + onContinue = ::onContinue, + ) + } + ) +} + +@Composable +private fun FullscreenAnnouncementHeader( + announcement: Announcement.Fullscreen, + modifier: Modifier = Modifier, +) { + IconTitleSubtitleMolecule( + modifier = modifier.padding(top = 16.dp, bottom = 16.dp), + title = announcement.title(), + showBetaLabel = true, + subTitle = announcement.subtitle(), + iconStyle = BigIcon.Style.Default( + vectorIcon = announcement.icon(), + usePrimaryTint = true, + ), + ) +} + +@Composable +private fun FullscreenAnnouncementContent( + announcement: Announcement.Fullscreen, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + ) { + InfoListOrganism( + modifier = Modifier.fillMaxWidth(), + items = announcement.items(), + textStyle = ElementTheme.typography.fontBodyLgMedium, + iconTint = ElementTheme.colors.iconSecondary, + iconSize = 24.dp + ) + announcement.notice()?.let { notice -> + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = notice, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun FullscreenAnnouncementFooter( + onContinue: () -> Unit, +) { + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 8.dp) + ) { + Button( + text = stringResource(id = CommonStrings.action_continue), + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun Announcement.Fullscreen.title() = when (this) { + Announcement.Fullscreen.Space -> "Introducing Spaces" +} + +@Composable +private fun Announcement.Fullscreen.subtitle() = when (this) { + Announcement.Fullscreen.Space -> "Welcome to the beta version of Spaces! With this first version you can:" +} + +@Composable +private fun Announcement.Fullscreen.icon() = when (this) { + Announcement.Fullscreen.Space -> CompoundIcons.SpaceSolid() +} + +@Composable +private fun Announcement.Fullscreen.items(): ImmutableList = when (this) { + Announcement.Fullscreen.Space -> persistentListOf( + InfoListItem( + message = "View spaces you\'ve created or joined", + iconVector = CompoundIcons.VisibilityOn(), + ), + InfoListItem( + message = "Accept or decline invites to spaces", + iconVector = CompoundIcons.Email(), + ), + InfoListItem( + message = "Discover any rooms you can join in your spaces", + iconVector = CompoundIcons.Search(), + ), + InfoListItem( + message = "Join public spaces", + iconVector = CompoundIcons.Explore(), + ), + InfoListItem( + message = "Leave any spaces you’ve joined", + iconVector = CompoundIcons.Leave(), + ), + ) +} + +@Composable +private fun Announcement.Fullscreen.notice(): String? = when (this) { + Announcement.Fullscreen.Space -> "Filtering, creating and managing spaces is coming soon." +} + +@PreviewsDayNight +@Composable +internal fun FullscreenAnnouncementViewPreview(@PreviewParameter(AnnouncementStateProvider::class) state: AnnouncementState) = ElementPreview { + FullscreenAnnouncementView( + state = state, + ) +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt deleted file mode 100644 index 3b968d09a6..0000000000 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.announcement.impl.spaces - -sealed interface SpaceAnnouncementEvents { - data object Continue : SpaceAnnouncementEvents -} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt deleted file mode 100644 index 7c4bc7b5eb..0000000000 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt +++ /dev/null @@ -1,40 +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.announcement.impl.spaces - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import dev.zacsweers.metro.Inject -import io.element.android.features.announcement.api.Announcement -import io.element.android.features.announcement.impl.store.AnnouncementStatus -import io.element.android.features.announcement.impl.store.AnnouncementStore -import io.element.android.libraries.architecture.Presenter -import kotlinx.coroutines.launch - -@Inject -class SpaceAnnouncementPresenter( - private val announcementStore: AnnouncementStore, -) : Presenter { - @Composable - override fun present(): SpaceAnnouncementState { - val localCoroutineScope = rememberCoroutineScope() - - fun handleEvent(event: SpaceAnnouncementEvents) { - when (event) { - SpaceAnnouncementEvents.Continue -> localCoroutineScope.launch { - announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) - } - } - } - - return SpaceAnnouncementState( - eventSink = ::handleEvent, - ) - } -} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt deleted file mode 100644 index 9407fad872..0000000000 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.announcement.impl.spaces - -data class SpaceAnnouncementState( - val eventSink: (SpaceAnnouncementEvents) -> Unit -) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt deleted file mode 100644 index 27f48cc7ed..0000000000 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.announcement.impl.spaces - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider - -open class SpaceAnnouncementStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aSpaceAnnouncementState(), - ) -} - -fun aSpaceAnnouncementState( - eventSink: (SpaceAnnouncementEvents) -> Unit = {}, -) = SpaceAnnouncementState( - eventSink = eventSink, -) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt deleted file mode 100644 index 3fe6ec4456..0000000000 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt +++ /dev/null @@ -1,158 +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.announcement.impl.spaces - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -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.announcement.impl.R -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.organisms.InfoListItem -import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism -import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage -import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.persistentListOf - -/** - * Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4593-40181 - */ -@Composable -fun SpaceAnnouncementView( - state: SpaceAnnouncementState, - modifier: Modifier = Modifier, -) { - val eventSink = state.eventSink - - fun onContinue() { - eventSink(SpaceAnnouncementEvents.Continue) - } - - BackHandler(onBack = ::onContinue) - HeaderFooterPage( - modifier = modifier, - isScrollable = true, - contentPadding = PaddingValues(top = 24.dp, start = 16.dp, end = 16.dp, bottom = 24.dp), - header = { - SpaceAnnouncementHeader() - }, - content = { - SpaceAnnouncementContent( - modifier = Modifier.padding(horizontal = 8.dp), - ) - }, - footer = { - SpaceAnnouncementFooter( - onContinue = ::onContinue, - ) - } - ) -} - -@Composable -private fun SpaceAnnouncementHeader( - modifier: Modifier = Modifier, -) { - IconTitleSubtitleMolecule( - modifier = modifier.padding(top = 16.dp, bottom = 16.dp), - title = stringResource(id = R.string.screen_space_announcement_title), - showBetaLabel = true, - subTitle = stringResource(id = R.string.screen_space_announcement_subtitle), - iconStyle = BigIcon.Style.Default( - vectorIcon = CompoundIcons.SpaceSolid(), - usePrimaryTint = true, - ), - ) -} - -@Composable -private fun SpaceAnnouncementContent( - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxSize(), - ) { - InfoListOrganism( - modifier = Modifier.fillMaxWidth(), - items = persistentListOf( - InfoListItem( - message = stringResource(id = R.string.screen_space_announcement_item1), - iconVector = CompoundIcons.VisibilityOn(), - ), - InfoListItem( - message = stringResource(id = R.string.screen_space_announcement_item2), - iconVector = CompoundIcons.Email(), - ), - InfoListItem( - message = stringResource(id = R.string.screen_space_announcement_item3), - iconVector = CompoundIcons.Search(), - ), - InfoListItem( - message = stringResource(id = R.string.screen_space_announcement_item4), - iconVector = CompoundIcons.Explore(), - ), - InfoListItem( - message = stringResource(id = R.string.screen_space_announcement_item5), - iconVector = CompoundIcons.Leave(), - ), - ), - textStyle = ElementTheme.typography.fontBodyLgMedium, - iconTint = ElementTheme.colors.iconSecondary, - iconSize = 24.dp - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - text = stringResource(id = R.string.screen_space_announcement_notice), - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } -} - -@Composable -private fun SpaceAnnouncementFooter( - onContinue: () -> Unit, -) { - ButtonColumnMolecule( - modifier = Modifier.padding(bottom = 8.dp) - ) { - Button( - text = stringResource(id = CommonStrings.action_continue), - onClick = onContinue, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@PreviewsDayNight -@Composable -internal fun SpaceAnnouncementViewPreview(@PreviewParameter(SpaceAnnouncementStateProvider::class) state: SpaceAnnouncementState) = ElementPreview { - SpaceAnnouncementView( - state = state, - ) -} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index ad166e4ef5..d24e9ed26e 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -17,7 +17,6 @@ import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFac import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") @ContributesBinding(AppScope::class) @@ -35,9 +34,10 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() + // Announcement.Fullscreen.Space is disabled, consider it's shown // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) val defaultStatus = when (announcement) { - Announcement.Space -> AnnouncementStatus.NeverShown + Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown } return store.data.map { prefs -> @@ -52,6 +52,6 @@ class DefaultAnnouncementStore( } private fun Announcement.toKey() = when (this) { - Announcement.Space -> spaceAnnouncementKey + is Announcement.Fullscreen -> intPreferencesKey("fullscreen_" + this::class.simpleName) Announcement.NewNotificationSound -> newNotificationSoundKey } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt index 18deb8b2fd..bcfd80942d 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt @@ -23,25 +23,25 @@ class AnnouncementPresenterTest { val presenter = createAnnouncementPresenter() presenter.test { val state = awaitItem() - assertThat(state.showSpaceAnnouncement).isFalse() + assertThat(state.announcement).isNull() } } @Test - fun `present - showSpaceAnnouncement value depends on the value in the store`() = runTest { + fun `present - showFullscreen value depends on the value in the store`() = runTest { val store = InMemoryAnnouncementStore() val presenter = createAnnouncementPresenter( announcementStore = store, ) presenter.test { val state = awaitItem() - assertThat(state.showSpaceAnnouncement).isFalse() - store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) + assertThat(state.announcement).isNull() + store.setAnnouncementStatus(Announcement.Fullscreen.Space, AnnouncementStatus.Show) val updatedState = awaitItem() - assertThat(updatedState.showSpaceAnnouncement).isTrue() - store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) + assertThat(updatedState.announcement).isEqualTo(Announcement.Fullscreen.Space) + store.setAnnouncementStatus(Announcement.Fullscreen.Space, AnnouncementStatus.Shown) val finalState = awaitItem() - assertThat(finalState.showSpaceAnnouncement).isFalse() + assertThat(finalState.announcement).isNull() } } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index e16619129c..c72d147c1d 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -11,31 +11,28 @@ package io.element.android.features.announcement.impl import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement -import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState -import io.element.android.features.announcement.impl.spaces.aSpaceAnnouncementState import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore -import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Test class DefaultAnnouncementServiceTest { @Test - fun `when showing Space announcement, space announcement is set to show only if it was never shown`() = runTest { + fun `when showing Fullscreen announcement, Fullscreen announcement is set to show only if it was never shown`() = runTest { val announcementStore = InMemoryAnnouncementStore() val sut = createDefaultAnnouncementService( announcementStore = announcementStore, ) - assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) - sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Show) + assertThat(announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.Fullscreen.Space) + assertThat(announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).first()).isEqualTo(AnnouncementStatus.Show) // Simulate user close the announcement - sut.onAnnouncementDismissed(Announcement.Space) + sut.onAnnouncementDismissed(Announcement.Fullscreen.Space) // Entering again the space tab should not change the value - sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) + sut.showAnnouncement(Announcement.Fullscreen.Space) + assertThat(announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).first()).isEqualTo(AnnouncementStatus.Shown) } @Test @@ -62,11 +59,7 @@ class DefaultAnnouncementServiceTest { ) sut.announcementsToShowFlow().test { assertThat(awaitItem()).isEmpty() - announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.Space) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.Space, Announcement.NewNotificationSound) - announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) assertThat(awaitItem()).isEmpty() @@ -75,11 +68,9 @@ class DefaultAnnouncementServiceTest { private fun createDefaultAnnouncementService( announcementStore: AnnouncementStore = InMemoryAnnouncementStore(), - announcementPresenter: Presenter = Presenter { anAnnouncementState() }, - spaceAnnouncementPresenter: Presenter = Presenter { aSpaceAnnouncementState() }, + announcementPresenter: AnnouncementPresenter = AnnouncementPresenter(announcementStore), ) = DefaultAnnouncementService( announcementStore = announcementStore, announcementPresenter = announcementPresenter, - spaceAnnouncementPresenter = spaceAnnouncementPresenter, ) } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementViewTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt similarity index 51% rename from features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementViewTest.kt rename to features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt index ad3d83f1b5..b69037e61a 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementViewTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt @@ -6,12 +6,16 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.announcement.impl.spaces +package io.element.android.features.announcement.impl.fullscreen import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.AnnouncementEvent +import io.element.android.features.announcement.impl.AnnouncementState +import io.element.android.features.announcement.impl.anAnnouncementState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn @@ -22,39 +26,41 @@ import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class SpaceAnnouncementViewTest { +class FullscreenAnnouncementViewTest { @get:Rule val rule = createAndroidComposeRule() @Test - fun `clicking on back sends a SpaceAnnouncementEvents`() { - val eventsRecorder = EventsRecorder() - rule.setSpaceAnnouncementView( - aSpaceAnnouncementState( + fun `clicking on back sends a AnnouncementEvent`() { + val eventsRecorder = EventsRecorder() + rule.setFullscreenAnnouncementView( + anAnnouncementState( + announcement = Announcement.Fullscreen.Space, eventSink = eventsRecorder, ), ) rule.pressBackKey() - eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue) + eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space)) } @Test - fun `clicking on Continue sends a SpaceAnnouncementEvents`() { - val eventsRecorder = EventsRecorder() - rule.setSpaceAnnouncementView( - aSpaceAnnouncementState( + fun `clicking on Continue sends a AnnouncementEvent`() { + val eventsRecorder = EventsRecorder() + rule.setFullscreenAnnouncementView( + anAnnouncementState( + announcement = Announcement.Fullscreen.Space, eventSink = eventsRecorder, ), ) rule.clickOn(CommonStrings.action_continue) - eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue) + eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space)) } } -private fun AndroidComposeTestRule.setSpaceAnnouncementView( - state: SpaceAnnouncementState, +private fun AndroidComposeTestRule.setFullscreenAnnouncementView( + state: AnnouncementState, ) { setContent { - SpaceAnnouncementView( + FullscreenAnnouncementView( state = state, ) } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt deleted file mode 100644 index 672f677407..0000000000 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt +++ /dev/null @@ -1,41 +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.announcement.impl.spaces - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.announcement.api.Announcement -import io.element.android.features.announcement.impl.store.AnnouncementStatus -import io.element.android.features.announcement.impl.store.AnnouncementStore -import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore -import io.element.android.tests.testutils.test -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class SpaceAnnouncementPresenterTest { - @Test - fun `present - when user continues, the store is updated`() = runTest { - val store = InMemoryAnnouncementStore() - val presenter = createSpaceAnnouncementPresenter( - announcementStore = store, - ) - presenter.test { - assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) - val state = awaitItem() - state.eventSink(SpaceAnnouncementEvents.Continue) - assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) - } - } -} - -private fun createSpaceAnnouncementPresenter( - announcementStore: AnnouncementStore = InMemoryAnnouncementStore(), -) = SpaceAnnouncementPresenter( - announcementStore = announcementStore, -) diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index ab3e85124f..ed6dfec850 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -14,10 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( - initialSpaceAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { - private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus) + private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { @@ -29,12 +29,12 @@ class InMemoryAnnouncementStore( } override suspend fun reset() { - spaceAnnouncement.value = AnnouncementStatus.NeverShown + fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { - Announcement.Space -> spaceAnnouncement + is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 5985e33127..1e49b1aa70 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -19,8 +19,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.features.announcement.api.Announcement -import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.logout.api.direct.DirectLogoutState @@ -47,7 +45,6 @@ class HomePresenter( private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, - private val announcementService: AnnouncementService, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @@ -82,10 +79,7 @@ class HomePresenter( fun handleEvent(event: HomeEvent) { when (event) { - is HomeEvent.SelectHomeNavigationBarItem -> coroutineState.launch { - if (event.item == HomeNavigationBarItem.Spaces) { - announcementService.showAnnouncement(Announcement.Space) - } + is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } is HomeEvent.SwitchToAccount -> coroutineState.launch { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 90cf160cc6..371a718523 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -9,14 +9,11 @@ package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat -import io.element.android.features.announcement.api.Announcement -import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.indicator.api.IndicatorService @@ -34,8 +31,6 @@ import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.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.flowOf import kotlinx.coroutines.test.runTest @@ -137,14 +132,10 @@ class HomePresenterTest { @Test fun `present - NavigationBar change`() = runTest { - val showAnnouncementResult = lambdaRecorder { } val presenter = createHomePresenter( sessionStore = InMemorySessionStore( updateUserProfileResult = { _, _, _ -> }, ), - announcementService = FakeAnnouncementService( - showAnnouncementResult = showAnnouncementResult, - ) ) presenter.test { val initialState = awaitItem() @@ -152,8 +143,6 @@ class HomePresenterTest { initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) val finalState = awaitItem() assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) - showAnnouncementResult.assertions().isCalledOnce() - .with(value(Announcement.Space)) } } } @@ -166,7 +155,6 @@ internal fun createHomePresenter( indicatorService: IndicatorService = FakeIndicatorService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), - announcementService: AnnouncementService = FakeAnnouncementService(), ) = HomePresenter( client = client, syncService = syncService, @@ -177,5 +165,4 @@ internal fun createHomePresenter( logoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, - announcementService = announcementService, ) From 78d8eaaf344377a49f69285e10f32bff0a0f6df2 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 10 Apr 2026 13:07:24 +0000 Subject: [PATCH 02/53] Update screenshots --- ...ent.impl.fullscreen_FullscreenAnnouncementView_Day_0_en.png | 3 +++ ...nt.impl.fullscreen_FullscreenAnnouncementView_Day_1_en.png} | 0 ...t.impl.fullscreen_FullscreenAnnouncementView_Night_0_en.png | 3 +++ ....impl.fullscreen_FullscreenAnnouncementView_Night_1_en.png} | 0 4 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_0_en.png rename tests/uitests/src/test/snapshots/images/{features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_en.png => features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_1_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_0_en.png rename tests/uitests/src/test/snapshots/images/{features.announcement.impl.spaces_SpaceAnnouncementView_Night_0_en.png => features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_1_en.png} (100%) diff --git a/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_0_en.png new file mode 100644 index 0000000000..1b6fb4bab8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650 +size 3642 diff --git a/tests/uitests/src/test/snapshots/images/features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_0_en.png new file mode 100644 index 0000000000..d6fd8eeb70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd +size 3659 diff --git a/tests/uitests/src/test/snapshots/images/features.announcement.impl.spaces_SpaceAnnouncementView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.announcement.impl.spaces_SpaceAnnouncementView_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.announcement.impl.fullscreen_FullscreenAnnouncementView_Night_1_en.png From 24d1900694b2c65854ef6e9d06cb041f7ee02e1f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 10 Apr 2026 18:55:37 +0200 Subject: [PATCH 03/53] Fix quality issues --- .../element/android/features/announcement/api/Announcement.kt | 4 ++++ .../android/features/announcement/impl/AnnouncementState.kt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index a83d167ee2..1d6f357ca8 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -8,7 +8,11 @@ package io.element.android.features.announcement.api +import androidx.compose.runtime.Immutable + +@Immutable sealed interface Announcement { + @Immutable sealed interface Fullscreen : Announcement { data object Space : Fullscreen } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt index 3ef47d6ed0..fb0732450d 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt @@ -11,6 +11,6 @@ package io.element.android.features.announcement.impl import io.element.android.features.announcement.api.Announcement data class AnnouncementState( - val announcement: Announcement.Fullscreen? = null, + val announcement: Announcement.Fullscreen?, val eventSink: (AnnouncementEvent) -> Unit, ) From 683b1fe9d576a4f8fe5ba6c9d8683d438d79f877 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 24 Feb 2026 16:37:03 +0100 Subject: [PATCH 04/53] Fix typo --- .../impl/screens/onboarding/classic/ElementClassicConnection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt index f895dd781e..d0ebbb336f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt @@ -107,7 +107,7 @@ class DefaultElementClassicConnection( if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { Timber.tag(loggerTag.value).d("Binding returned true") } else { - // This happen when the app is not installed + // This happens when the app is not installed Timber.tag(loggerTag.value).d("Binding returned false") mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) } From 8c5caabed488e28ae725d515a185c1bce49d702b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 4 Mar 2026 16:11:37 +0100 Subject: [PATCH 05/53] Sign in with Classic --- .../io/element/android/appnav/RootFlowNode.kt | 3 +- .../features/login/impl/LoginFlowNode.kt | 73 ++- .../login/impl/LoginFlowTransitionHandler.kt | 54 +++ .../impl/classic/ElementClassicConnection.kt | 382 ++++++++++++++++ .../features/login/impl/di/LoginModule.kt | 5 - .../features/login/impl/login/LoginHelper.kt | 11 +- .../ChooseAccountProviderPresenter.kt | 1 + .../impl/screens/classic/ClassicFlowNode.kt | 143 ++++++ .../screens/classic/ClassicFlowNodeHelper.kt | 78 ++++ .../impl/screens/classic/NavigationEvent.kt | 18 + .../LoginWithClassicEvent.kt | 7 +- .../LoginWithClassicNavigator.kt | 12 + .../loginwithclassic/LoginWithClassicNode.kt | 69 +++ .../LoginWithClassicPresenter.kt | 109 +++++ .../loginwithclassic/LoginWithClassicState.kt | 26 ++ .../LoginWithClassicStateProvider.kt | 41 ++ .../loginwithclassic/LoginWithClassicView.kt | 226 +++++++++ .../missingkeybackup/MissingKeyBackupEvent.kt | 12 + .../missingkeybackup/MissingKeyBackupNode.kt | 70 +++ .../MissingKeyBackupPresenter.kt | 45 ++ .../missingkeybackup/MissingKeyBackupState.kt | 13 + .../MissingKeyBackupStateProvider.kt | 26 ++ .../missingkeybackup/MissingKeyBackupView.kt | 92 ++++ .../impl/screens/classic/root/RootNode.kt | 30 ++ .../impl/screens/classic/root/RootView.kt | 41 ++ .../ConfirmAccountProviderPresenter.kt | 1 + .../loginpassword/LoginPasswordNode.kt | 11 +- .../loginpassword/LoginPasswordPresenter.kt | 20 +- .../impl/screens/onboarding/OnBoardingNode.kt | 2 + .../screens/onboarding/OnBoardingPresenter.kt | 7 +- .../screens/onboarding/OnBoardingState.kt | 3 +- .../onboarding/OnBoardingStateProvider.kt | 9 +- .../impl/screens/onboarding/OnBoardingView.kt | 89 ++-- .../classic/ElementClassicConnection.kt | 260 ----------- .../classic/LoginWithClassicPresenter.kt | 103 ----- .../classic/LoginWithClassicState.kt | 16 - .../classic/LoginWithClassicStateProvider.kt | 20 - .../res/drawable-xxhdpi/element_foss_logo.png | Bin 0 -> 13888 bytes .../res/drawable-xxhdpi/element_pro_logo.png | Bin 0 -> 26428 bytes .../impl/src/main/res/values/localazy.xml | 8 + .../login/impl/DefaultLoginEntryPointTest.kt | 2 + .../DefaultElementClassicConnectionTest.kt | 429 ++++++++++++++++++ .../classic/FakeElementClassicConnection.kt | 9 +- .../login/impl/classic/FakeServiceBinder.kt | 26 ++ .../features/login/impl/classic/Fixtures.kt | 39 ++ .../classic/ClassicFlowNodeHelperTest.kt | 278 ++++++++++++ .../FakeLoginWithClassicNavigator.kt | 18 + .../LoginWithClassicPresenterTest.kt | 293 ++++++++++++ .../MissingKeyBackupPresenterTest.kt | 55 +++ .../LoginPasswordPresenterTest.kt | 17 + .../onboarding/OnBoardingPresenterTest.kt | 31 +- .../classic/LoginWithClassicPresenterTest.kt | 214 --------- .../androidutils/service/ServiceBinder.kt | 33 ++ .../appyx/FaderOrSliderTransitionHandler.kt | 54 +++ .../components/avatar/BitmapAvatar.kt | 79 ++++ .../matrix/api/auth/ElementClassicSession.kt | 11 +- .../api/auth/MatrixAuthenticationService.kt | 15 + .../libraries/matrix/api/core/UserIdTest.kt | 20 + .../auth/RustMatrixAuthenticationService.kt | 72 ++- .../auth/FakeMatrixAuthenticationService.kt | 12 + tools/localazy/checkForbiddenTerms.py | 3 + tools/localazy/config.json | 2 + 62 files changed, 3120 insertions(+), 728 deletions(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt rename features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/{onboarding/classic => classic/loginwithclassic}/LoginWithClassicEvent.kt (54%) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt create mode 100644 features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png create mode 100644 features/login/impl/src/main/res/drawable-xxhdpi/element_pro_logo.png create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt rename features/login/impl/src/test/kotlin/io/element/android/features/login/impl/{screens/onboarding => }/classic/FakeElementClassicConnection.kt (72%) create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt delete mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt rename features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt => libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt (56%) create mode 100644 libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 745ab390b2..0e458d3b9c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -252,7 +252,8 @@ class RootFlowNode( val transitionHandler = rememberDelegateTransitionHandler { navTarget -> when (navTarget) { is NavTarget.SplashScreen, - is NavTarget.LoggedInFlow -> backstackFader + is NavTarget.LoggedInFlow, + is NavTarget.NotLoggedInFlow -> backstackFader else -> backstackSlider } } 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 928d98c244..bc928a936f 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 @@ -22,6 +22,7 @@ import com.bumble.appyx.core.plugin.Plugin 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 @@ -30,9 +31,11 @@ import io.element.android.annotations.ContributesNode import io.element.android.compound.theme.ElementTheme 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 @@ -63,9 +66,10 @@ class LoginFlowNode( private val oidcActionFlow: OidcActionFlow, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val elementClassicConnection: ElementClassicConnection, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.OnBoarding, + initialElement = NavTarget.CheckClassicFlow, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -103,7 +107,12 @@ class LoginFlowNode( sealed interface NavTarget : Parcelable { @Parcelize - data object OnBoarding : NavTarget + data object CheckClassicFlow : NavTarget + + @Parcelize + data class OnBoarding( + val showBackButton: Boolean, + ) : NavTarget @Parcelize data object QrCode : NavTarget @@ -123,7 +132,9 @@ class LoginFlowNode( data object SearchAccountProvider : NavTarget @Parcelize - data object LoginPassword : NavTarget + data class LoginPassword( + val initialLogin: String = "", + ) : NavTarget @Parcelize data class CreateAccount(val url: String) : NavTarget @@ -131,7 +142,31 @@ class LoginFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.OnBoarding -> { + NavTarget.CheckClassicFlow -> { + val callback = object : ClassicFlowNode.Callback { + override fun navigateToOnBoarding(allowBackNavigation: Boolean) { + if (allowBackNavigation) { + backstack.push(NavTarget.OnBoarding(showBackButton = true)) + } else { + backstack.replace(NavTarget.OnBoarding(showBackButton = false)) + } + } + + override fun navigateToLoginPassword() { + backstack.push(NavTarget.LoginPassword()) + } + + override fun navigateToOidc(oidcDetails: OidcDetails) { + navigateToMas(oidcDetails) + } + + override fun navigateToCreateAccount(url: String) { + backstack.push(NavTarget.CreateAccount(url)) + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.OnBoarding -> { val callback = object : OnBoardingNode.Callback { override fun navigateToSignUpFlow() { backstack.push( @@ -166,17 +201,22 @@ class LoginFlowNode( } override fun navigateToLoginPassword() { - backstack.push(NavTarget.LoginPassword) + backstack.push(NavTarget.LoginPassword()) } override fun onDone() { - callback.onDone() + if (navTarget.showBackButton) { + backstack.pop() + } else { + callback.onDone() + } } } val params = inputs() val inputs = OnBoardingNode.Params( accountProvider = params.accountProvider, loginHint = params.loginHint, + showBackButton = navTarget.showBackButton, ) createNode(buildContext, listOf(callback, inputs)) } @@ -191,7 +231,7 @@ class LoginFlowNode( } override fun navigateToLoginPassword() { - backstack.push(NavTarget.LoginPassword) + backstack.push(NavTarget.LoginPassword()) } } createNode(buildContext, listOf(callback)) @@ -218,7 +258,7 @@ class LoginFlowNode( } override fun navigateToLoginPassword() { - backstack.push(NavTarget.LoginPassword) + backstack.push(NavTarget.LoginPassword()) } override fun navigateToChangeAccountProvider() { @@ -257,8 +297,11 @@ class LoginFlowNode( createNode(buildContext, plugins = listOf(callback)) } - NavTarget.LoginPassword -> { - createNode(buildContext) + is NavTarget.LoginPassword -> { + val inputs = LoginPasswordNode.Inputs( + initialLogin = navTarget.initialLogin, + ) + createNode(buildContext, plugins = listOf(inputs)) } is NavTarget.CreateAccount -> { val inputs = CreateAccountNode.Inputs( @@ -280,6 +323,14 @@ class LoginFlowNode( override fun View(modifier: Modifier) { activity = requireNotNull(LocalActivity.current) darkTheme = !ElementTheme.isLightTheme + + DisposableEffect(Unit) { + elementClassicConnection.start() + onDispose { + elementClassicConnection.stop() + } + } + DisposableEffect(Unit) { onDispose { activity = null @@ -288,6 +339,6 @@ class LoginFlowNode( } } } - BackstackView() + BackstackView(transitionHandler = rememberLoginFlowTransitionHandler()) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt new file mode 100644 index 0000000000..5486619e5d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt @@ -0,0 +1,54 @@ +/* + * 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.impl + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler +import com.bumble.appyx.core.navigation.transition.TransitionDescriptor +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.Replace +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider +import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler + +/** + * A TransitionHandler that uses fade transition when OnBoarding is replacing the current screen, + * and slide transition for all other cases. + */ +private class LoginFlowTransitionHandler( + private val slider: ModifierTransitionHandler, + private val fader: ModifierTransitionHandler, +) : ModifierTransitionHandler() { + override fun createModifier( + modifier: Modifier, + transition: Transition, + descriptor: TransitionDescriptor + ): Modifier { + val useFader = descriptor.element is LoginFlowNode.NavTarget.OnBoarding && + descriptor.operation is Replace + val handler = if (useFader) fader else slider + return handler.createModifier(modifier, transition, descriptor) + } +} + +@Composable +fun rememberLoginFlowTransitionHandler(): ModifierTransitionHandler { + val slider = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val fader = rememberBackstackFader( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + return rememberDelegateTransitionHandler { + LoginFlowTransitionHandler(slider, fader) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt new file mode 100644 index 0000000000..5e838094d3 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -0,0 +1,382 @@ +/* + * 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.impl.classic + +import android.content.ComponentName +import android.content.Context.BIND_AUTO_CREATE +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.Bitmap +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import androidx.annotation.VisibleForTesting +import androidx.core.os.BundleCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.login.impl.BuildConfig +import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.auth.ElementClassicSession +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +interface ElementClassicConnection { + fun start() + fun stop() + fun requestSession() + fun requestAvatar(userId: UserId) + val stateFlow: StateFlow +} + +sealed interface ElementClassicConnectionState { + object Idle : ElementClassicConnectionState + object ElementClassicNotFound : ElementClassicConnectionState + object ElementClassicReadyNoSession : ElementClassicConnectionState + data class ElementClassicReady( + val elementClassicSession: ElementClassicSession, + val displayName: String?, + val avatar: Bitmap?, + ) : ElementClassicConnectionState + + data class Error(val error: String) : ElementClassicConnectionState +} + +private val loggerTag = LoggerTag("ECConnection") + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultElementClassicConnection( + private val serviceBinder: ServiceBinder, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val matrixAuthenticationService: MatrixAuthenticationService, + private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, +) : ElementClassicConnection { + // Messenger for communicating with the service. + private var messenger: Messenger? = null + + // Target we publish for external service to send messages to IncomingHandler. + private val incomingMessenger: Messenger = Messenger(IncomingHandler()) + + // Flag indicating whether we have called bind on the service. + private var bound: Boolean = false + + /** + * Class for interacting with the main interface of the service. + */ + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + Timber.tag(loggerTag.value).d("onServiceConnected") + // This is called when the connection with the service has been + // established, giving us the object we can use to + // interact with the service. We are communicating with the + // service using a Messenger, so here we get a client-side + // representation of that from the raw IBinder object. + messenger = Messenger(service) + bound = true + // Request the data as soon as possible + requestSession() + } + + override fun onServiceDisconnected(className: ComponentName) { + Timber.tag(loggerTag.value).d("onServiceDisconnected") + // This is called when the connection with the service has been + // unexpectedly disconnected—that is, its process crashed. + messenger = null + bound = false + } + } + + override fun start() { + Timber.tag(loggerTag.value).w("start()") + coroutineScope.launch { + // Establish a connection with the service. We use an explicit + // class name because there is no reason to be able to let other + // applications replace our component. + try { + val intentService = Intent() + intentService.setComponent(getElementClassicComponent()) + if (serviceBinder.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { + Timber.tag(loggerTag.value).d("Binding returned true") + } else { + // This happens when the app is not installed + Timber.tag(loggerTag.value).d("Binding returned false") + emitState(ElementClassicConnectionState.ElementClassicNotFound) + } + } catch (e: SecurityException) { + Timber.tag(loggerTag.value).e(e, "Can't bind to Service") + emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + + override fun stop() { + Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") + if (bound) { + // Detach our existing connection. + serviceBinder.unbindService(serviceConnection) + bound = false + } + coroutineScope.launch { + emitState(ElementClassicConnectionState.Idle) + } + } + + override fun requestSession() { + Timber.tag(loggerTag.value).w("requestSession()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).w("The messenger is null, can't request data") + // Do not emit error, else the regular on boarding flow will be displayed + // emitState(ElementClassicConnectionState.Error("The messenger is null, can't request data")) + } else { + try { + // Get the data + val msg = Message.obtain(null, MSG_GET_SESSION) + msg.replyTo = incomingMessenger + finalMessenger.send(msg) + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + Timber.tag(loggerTag.value).e(e, "RemoteException") + emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + } + + override fun requestAvatar(userId: UserId) { + Timber.tag(loggerTag.value).w("requestAvatar()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).w("The messenger is null, can't request extra data") + } else { + try { + // Get the data + val msg = Message.obtain(null, MSG_GET_AVATAR) + msg.data = Bundle().apply { + putString(KEY_USER_ID_STR, userId.value) + } + msg.replyTo = incomingMessenger + finalMessenger.send(msg) + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + Timber.tag(loggerTag.value).e(e, "RemoteException") + } + } + } + } + + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() + + /** + * Handler of incoming messages from service. + */ + @Suppress("DEPRECATION") + inner class IncomingHandler : Handler() { + override fun handleMessage(msg: Message) { + Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}") + when (msg.what) { + MSG_GET_SESSION -> onSessionReceived(msg.data) + MSG_GET_AVATAR -> onAvatarReceived(msg.data) + else -> { + Timber.tag(loggerTag.value).w("Received unknown message ${msg.what}") + super.handleMessage(msg) + } + } + } + } + + @VisibleForTesting + fun onSessionReceived(data: Bundle) { + // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied + val state = data.toElementClassicConnectionState() + coroutineScope.launch { + val updatedState = ensureHomeserverIsSupported(state) + emitState(updatedState) + } + } + + @VisibleForTesting + fun onAvatarReceived(data: Bundle) { + val currentState = stateFlow.value + if (currentState is ElementClassicConnectionState.ElementClassicReady) { + // Check that the userId is still the same + val userId = data.getString(KEY_USER_ID_STR) + if (userId != currentState.elementClassicSession.userId.value) { + Timber.tag(loggerTag.value).w( + "Received profile data for userId $userId but current" + + " userId is ${currentState.elementClassicSession.userId}, ignoring" + ) + } else { + val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java) + val updatedState = currentState.copy( + avatar = avatar, + ) + coroutineScope.launch { + emitState(updatedState) + } + } + } else { + Timber.tag(loggerTag.value).w("Received profile data but current state is not ElementClassicReady: %s", currentState) + } + } + + private suspend fun ensureHomeserverIsSupported(state: ElementClassicConnectionState): ElementClassicConnectionState { + return if (state is ElementClassicConnectionState.ElementClassicReady) { + val elementXCanConnect = setOfNotNull( + // Try with the domain name first + state.elementClassicSession.userId.domainName?.ensureProtocol(), + // Then try with the resolved homeserver URL, if provided and distinct + state.elementClassicSession.homeserverUrl, + ).any { url -> + val isCompatible = homeServerLoginCompatibilityChecker.check(url) + .onFailure { + Timber.tag(loggerTag.value).w(it, "Failed to check compatibility with homeserver: $url") + } + .getOrNull() == true + if (isCompatible) { + Timber.tag(loggerTag.value).d("Found compatible homeserver URL: %s", url) + } else { + Timber.tag(loggerTag.value).w("Homeserver URL is not compatible: %s", url) + } + isCompatible + } + if (elementXCanConnect) { + state + } else { + Timber.tag(loggerTag.value).w("Cannot import session because the homeserver is not compatible with Element X") + ElementClassicConnectionState.Error("The homeserver is not compatible with Element X") + } + } else { + state + } + } + + private suspend fun emitState(state: ElementClassicConnectionState) { + when (state) { + is ElementClassicConnectionState.Error -> { + Timber.tag(loggerTag.value).w("Error: %s", state.error) + } + is ElementClassicConnectionState.ElementClassicReady -> { + Timber.tag(loggerTag.value).d("Ready state for user: %s", state.elementClassicSession.userId) + } + ElementClassicConnectionState.ElementClassicReadyNoSession -> { + Timber.tag(loggerTag.value).d("No session from Element Classic") + } + ElementClassicConnectionState.ElementClassicNotFound -> { + Timber.tag(loggerTag.value).d("Element Classic not found") + } + ElementClassicConnectionState.Idle -> { + Timber.tag(loggerTag.value).d("Idle") + } + } + // Also give the Element Classic session info to the MatrixAuthenticationService + matrixAuthenticationService.setElementClassicSession( + session = (state as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession + ) + mutableStateFlow.emit(state) + } + + private fun getElementClassicComponent() = ComponentName( + BuildConfig.elementClassicPackage, + ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, + ) + + private fun Bundle.toElementClassicConnectionState(): ElementClassicConnectionState { + val error = getString(KEY_ERROR_STR) + return if (error != null) { + ElementClassicConnectionState.Error(error) + } else { + val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId) + if (userId == null) { + ElementClassicConnectionState.ElementClassicReadyNoSession + } else { + val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } + val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR)?.takeIf { it.isNotEmpty() } + val homeserverUrl = getString(KEY_HOMESERVER_URL_STR)?.takeIf { it.isNotEmpty() } + val displayName = getString(KEY_USER_DISPLAY_NAME_STR)?.takeIf { it.isNotEmpty() } + val doesContainBackupKey = secrets != null && + roomKeysVersion != null && + matrixAuthenticationService.doSecretsContainBackupKey(userId, secrets, roomKeysVersion) + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = userId, + homeserverUrl = homeserverUrl, + secrets = secrets, + roomKeysVersion = roomKeysVersion, + doesContainBackupKey = doesContainBackupKey, + ), + displayName = displayName, + avatar = null, + ) + } + } + } + + // Everything in this companion object must match what is defined in Element Classic + companion object { + const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService" + + // Command to the service to get the userId/displayName/secrets of a verified session. + const val MSG_GET_SESSION = 1 + + // Command to the service to get the avatar oor the session. + const val MSG_GET_AVATAR = 2 + + // Keys for the bundle returned from the service + const val KEY_ERROR_STR = "error" + const val KEY_USER_ID_STR = "userId" + const val KEY_HOMESERVER_URL_STR = "homeserverUrl" + const val KEY_USER_DISPLAY_NAME_STR = "displayName" + + /** + * Key to extract the secrets from the bundle, as a Json string. + * Json will have this format: + * { + * "cross_signing" : { + * "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o", + * "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms", + * "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM" + * }, + * "backup" : { + * "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2", + * "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc", + * "backup_version" : "1" + * } + * } + */ + const val KEY_SECRETS_STR = "secrets" + const val KEY_ROOM_KEYS_VERSION_STR = "roomKeysVersion" + + // For the avatar + const val KEY_USER_AVATAR_PARCELABLE = "avatar" + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt index 12b9106b71..4523e6f45e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt @@ -14,8 +14,6 @@ import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesTo import io.element.android.features.login.impl.changeserver.ChangeServerPresenter import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.Presenter @ContributesTo(AppScope::class) @@ -23,7 +21,4 @@ import io.element.android.libraries.architecture.Presenter interface LoginModule { @Binds fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter - - @Binds - fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt index a62919e705..78be770bfc 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt @@ -60,10 +60,19 @@ class LoginHelper( suspend fun submit( isAccountCreation: Boolean, homeserverUrl: String, + resolvedHomeserverUrl: String?, loginHint: String?, ) { suspend { - authenticationService.setHomeserver(homeserverUrl).map { matrixHomeServerDetails -> + authenticationService.setHomeserver(homeserverUrl).recoverCatching { + // No .well-known file? + // If the homeserver is not reachable, try using resolvedHomeserverUrl. + if (resolvedHomeserverUrl != null && resolvedHomeserverUrl != homeserverUrl) { + authenticationService.setHomeserver(resolvedHomeserverUrl).getOrThrow() + } else { + throw it + } + }.map { matrixHomeServerDetails -> if (matrixHomeServerDetails.supportsOidcLogin) { // Retrieve the details right now val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt index 87010a4a30..c6d6d76486 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter( loginHelper.submit( isAccountCreation = false, homeserverUrl = it.url, + resolvedHomeserverUrl = null, loginHint = null, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt new file mode 100644 index 0000000000..8d79453318 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt @@ -0,0 +1,143 @@ +/* + * 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.impl.screens.classic + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +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.screens.classic.loginwithclassic.LoginWithClassicNode +import io.element.android.features.login.impl.screens.classic.missingkeybackup.MissingKeyBackupNode +import io.element.android.features.login.impl.screens.classic.root.RootNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.rememberFaderOrSliderTransitionHandler +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class ClassicFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val classicFlowNodeHelper: ClassicFlowNodeHelper, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + interface Callback : Plugin { + fun navigateToOnBoarding(allowBackNavigation: Boolean) + fun navigateToLoginPassword() + fun navigateToOidc(oidcDetails: OidcDetails) + fun navigateToCreateAccount(url: String) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class LoginWithClassic( + val userId: UserId, + ) : NavTarget + + @Parcelize + data object MissingKeyBackup : NavTarget + } + + private val callback: Callback = callback() + + override fun onBuilt() { + super.onBuilt() + observeElementClassicConnection() + } + + private fun observeElementClassicConnection() { + classicFlowNodeHelper.navigationEventFlow().onEach { navigationEvent -> + when (navigationEvent) { + is NavigationEvent.Idle -> Unit + is NavigationEvent.NavigateToOnBoarding -> callback.navigateToOnBoarding(allowBackNavigation = false) + is NavigationEvent.NavigateToLoginWithClassic -> backstack.newRoot(NavTarget.LoginWithClassic(navigationEvent.userId)) + } + }.launchIn(lifecycleScope) + } + + override fun resolve( + navTarget: NavTarget, + buildContext: BuildContext, + ): Node { + return when (navTarget) { + NavTarget.Root -> { + createNode(buildContext) + } + is NavTarget.LoginWithClassic -> { + val callback = object : LoginWithClassicNode.Callback { + override fun navigateToOtherOptions() { + callback.navigateToOnBoarding(allowBackNavigation = true) + } + + override fun navigateToLoginPassword() { + callback.navigateToLoginPassword() + } + + override fun navigateToOidc(oidcDetails: OidcDetails) { + callback.navigateToOidc(oidcDetails) + } + + override fun navigateToCreateAccount(url: String) { + callback.navigateToCreateAccount(url) + } + + override fun navigateToMissingKeyBackup() { + backstack.push(NavTarget.MissingKeyBackup) + } + } + val inputs = LoginWithClassicNode.Inputs( + userId = navTarget.userId, + ) + createNode(buildContext, plugins = listOf(inputs, callback)) + } + NavTarget.MissingKeyBackup -> { + val callback = object : MissingKeyBackupNode.Callback { + override fun navigateBack() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView( + modifier = modifier, + transitionHandler = rememberFaderOrSliderTransitionHandler(), + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt new file mode 100644 index 0000000000..f719fe5083 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic + +import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.take + +@Inject +class ClassicFlowNodeHelper( + private val elementClassicConnection: ElementClassicConnection, + private val sessionStore: SessionStore, +) { + // Ensure user is not stuck on the loading screen. + // If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds. + private val timeoutFLow = flow { + emit(false) + delay(5_000) + emit(true) + } + + fun navigationEventFlow(): Flow { + return combine( + timeoutFLow, + elementClassicConnection.stateFlow + .distinctUntilChangedBy { + // Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar + if (it is ElementClassicConnectionState.ElementClassicReady) { + it.copy(avatar = null) + } else { + it + } + }, + sessionStore.sessionsFlow().toUserListFlow() + // Take only 1 emission of the sessions, else when the user actually logged in it will trigger a navigation to OnBoarding. + .take(1), + ) { timeout, elementClassicConnectionState, existingSessions -> + when (elementClassicConnectionState) { + ElementClassicConnectionState.Idle -> { + if (timeout) { + NavigationEvent.NavigateToOnBoarding + } else { + NavigationEvent.Idle + } + } + ElementClassicConnectionState.ElementClassicNotFound, + ElementClassicConnectionState.ElementClassicReadyNoSession, + is ElementClassicConnectionState.Error -> { + NavigationEvent.NavigateToOnBoarding + } + is ElementClassicConnectionState.ElementClassicReady -> { + if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) { + NavigationEvent.NavigateToOnBoarding + } else { + // 2 cases when this can be run: + // First time this screen will be displayed + // Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic), + // and the app is resuming. + NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId) + } + } + } + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt new file mode 100644 index 0000000000..cddca0015b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.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.login.impl.screens.classic + +import io.element.android.libraries.matrix.api.core.UserId + +sealed interface NavigationEvent { + data object Idle : NavigationEvent + data object NavigateToOnBoarding : NavigationEvent + data class NavigateToLoginWithClassic( + val userId: UserId, + ) : NavigationEvent +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt similarity index 54% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt index 75a9496a02..e3c6ed782d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt @@ -5,11 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.login.impl.screens.onboarding.classic +package io.element.android.features.login.impl.screens.classic.loginwithclassic sealed interface LoginWithClassicEvent { data object RefreshData : LoginWithClassicEvent - data object StartLoginWithClassic : LoginWithClassicEvent - data object DoLoginWithClassic : LoginWithClassicEvent - data object CloseDialog : LoginWithClassicEvent + data object Submit : LoginWithClassicEvent + data object ClearError : LoginWithClassicEvent } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt new file mode 100644 index 0000000000..55716c2cf7 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt @@ -0,0 +1,12 @@ +/* + * 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.impl.screens.classic.loginwithclassic + +interface LoginWithClassicNavigator { + fun navigateToMissingKeyBackup() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt new file mode 100644 index 0000000000..c42248a3f8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt @@ -0,0 +1,69 @@ +/* + * 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.impl.screens.classic.loginwithclassic + +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.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesNode(AppScope::class) +@AssistedInject +class LoginWithClassicNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: LoginWithClassicPresenter.Factory, +) : Node(buildContext, plugins = plugins), + LoginWithClassicNavigator { + interface Callback : Plugin { + fun navigateToOtherOptions() + fun navigateToLoginPassword() + fun navigateToOidc(oidcDetails: OidcDetails) + fun navigateToCreateAccount(url: String) + fun navigateToMissingKeyBackup() + } + + data class Inputs( + val userId: UserId, + ) : NodeInputs + + private val inputs: Inputs = inputs() + val presenter = presenterFactory.create(inputs.userId, this) + private val callback: Callback = callback() + + override fun navigateToMissingKeyBackup() { + callback.navigateToMissingKeyBackup() + } + + @Composable + override fun View(modifier: Modifier) { + val context = LocalContext.current + val state = presenter.present() + LoginWithClassicView( + state = state, + modifier = modifier, + onOtherOptionsClick = callback::navigateToOtherOptions, + onOidcDetails = callback::navigateToOidc, + onNeedLoginPassword = callback::navigateToLoginPassword, + onLearnMoreClick = { openLearnMorePage(context) }, + onCreateAccountContinue = callback::navigateToCreateAccount, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt new file mode 100644 index 0000000000..6494ee741e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt @@ -0,0 +1,109 @@ +/* + * 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.impl.screens.classic.loginwithclassic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.launch + +@AssistedInject +class LoginWithClassicPresenter( + @Assisted private val userId: UserId, + @Assisted private val navigator: LoginWithClassicNavigator, + private val loginHelper: LoginHelper, + private val elementClassicConnection: ElementClassicConnection, + private val accountProviderDataSource: AccountProviderDataSource, + private val buildMeta: BuildMeta, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + userId: UserId, + navigator: LoginWithClassicNavigator, + ): LoginWithClassicPresenter + } + + @Composable + override fun present(): LoginWithClassicState { + val coroutineScope = rememberCoroutineScope() + var loginWithClassicAction by remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + val loginMode by loginHelper.collectLoginMode() + val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState() + + fun handleEvent(event: LoginWithClassicEvent) { + when (event) { + LoginWithClassicEvent.RefreshData -> { + // Request the avatar if not known yet + val currentState = elementClassicConnection.stateFlow.value + if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) { + elementClassicConnection.requestAvatar(userId) + } + } + LoginWithClassicEvent.Submit -> { + val currentState = elementClassicConnection.stateFlow.value + if (currentState is ElementClassicConnectionState.ElementClassicReady) { + if (currentState.elementClassicSession.secrets != null && + !currentState.elementClassicSession.doesContainBackupKey) { + navigator.navigateToMissingKeyBackup() + } else { + coroutineScope.launch { + loginWithClassicAction = AsyncAction.Loading + // Ensure that the current account provider is set + val elementClassicUserId = currentState.elementClassicSession.userId + val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol() + accountProviderDataSource.setUrl(accountProvider) + loginHelper.submit( + isAccountCreation = false, + homeserverUrl = accountProvider, + resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl, + loginHint = "mxid:" + elementClassicUserId.value, + ) + } + } + } else { + loginWithClassicAction = AsyncAction.Failure(IllegalStateException("Element Classic is not ready")) + } + } + LoginWithClassicEvent.ClearError -> { + loginWithClassicAction = AsyncAction.Uninitialized + loginHelper.clearError() + } + } + } + + val elementClassicReady = elementClassicConnectionState as? ElementClassicConnectionState.ElementClassicReady + return LoginWithClassicState( + isElementPro = buildMeta.isEnterpriseBuild, + userId = userId, + displayName = elementClassicReady?.displayName, + avatar = elementClassicReady?.avatar, + loginMode = loginMode, + loginWithClassicAction = loginWithClassicAction, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt new file mode 100644 index 0000000000..275a444768 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.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.login.impl.screens.classic.loginwithclassic + +import android.graphics.Bitmap +import androidx.compose.runtime.Stable +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId + +@Stable +data class LoginWithClassicState( + val isElementPro: Boolean, + val userId: UserId, + val displayName: String?, + val avatar: Bitmap?, + val loginWithClassicAction: AsyncAction, + val loginMode: AsyncData, + val eventSink: (LoginWithClassicEvent) -> Unit, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt new file mode 100644 index 0000000000..d8dcfeb072 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt @@ -0,0 +1,41 @@ +/* + * 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.impl.screens.classic.loginwithclassic + +import android.graphics.Bitmap +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId + +open class LoginWithClassicStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLoginWithClassicState(), + aLoginWithClassicState(isElementPro = true, displayName = "Alice"), + ) +} + +fun aLoginWithClassicState( + isElementPro: Boolean = false, + userId: UserId = UserId("@alice:matrix.org"), + displayName: String? = null, + avatar: Bitmap? = null, + loginWithClassicAction: AsyncAction = AsyncAction.Uninitialized, + loginMode: AsyncData = AsyncData.Uninitialized, + eventSink: (LoginWithClassicEvent) -> Unit = {}, +) = LoginWithClassicState( + isElementPro = isElementPro, + userId = userId, + displayName = displayName, + avatar = avatar, + loginWithClassicAction = loginWithClassicAction, + loginMode = loginMode, + eventSink = eventSink, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt new file mode 100644 index 0000000000..aeb61946ff --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt @@ -0,0 +1,226 @@ +/* + * 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.impl.screens.classic.loginwithclassic + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.impl.R +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.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.background.OnboardingBackground +import io.element.android.libraries.designsystem.components.async.AsyncActionView +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.BitmapAvatar +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.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginWithClassicView( + state: LoginWithClassicState, + onOtherOptionsClick: () -> Unit, + onOidcDetails: (OidcDetails) -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, + onCreateAccountContinue: (url: String) -> Unit, + modifier: Modifier = Modifier, +) { + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + state.eventSink(LoginWithClassicEvent.RefreshData) + } + + val isLoading by remember(state.loginMode) { + derivedStateOf { + state.loginMode is AsyncData.Loading + } + } + + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + background = { OnboardingBackground() }, + isScrollable = true, + header = { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(40.dp)) + Box( + modifier = Modifier + .size(54.dp) + .shadow(elevation = 10.dp, shape = RoundedCornerShape(15.dp)) + .background(ElementTheme.colors.bgCanvasDefault, shape = RoundedCornerShape(15.dp)), + contentAlignment = Alignment.Center, + ) { + val resId = if (state.isElementPro) { + R.drawable.element_pro_logo + } else { + R.drawable.element_foss_logo + } + Image( + modifier = Modifier.size(37.5.dp), + painter = painterResource(id = resId), + contentDescription = null, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.screen_onboarding_welcome_title), + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontHeadingMdBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + } + }, + content = { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(40.dp)) + BitmapAvatar( + avatarData = AvatarData( + id = state.userId.value, + name = state.displayName, + // Not used here + url = null, + size = AvatarSize.UserHeader, + ), + bitmap = state.avatar, + ) + Spacer(Modifier.height(24.dp)) + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResource(R.string.screen_onboarding_welcome_back), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + // User display name + if (state.displayName != null) { + Text( + text = state.displayName, + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + } + // UserId + Text( + text = state.userId.value, + style = if (state.displayName == null) ElementTheme.typography.fontHeadingLgBold else ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + // Min spacing + Spacer(Modifier.height(45.dp)) + ButtonColumnMolecule { + Button( + text = stringResource(CommonStrings.action_continue), + showProgress = isLoading, + onClick = { + state.eventSink(LoginWithClassicEvent.Submit) + }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + OutlinedButton( + text = stringResource(CommonStrings.common_other_options), + onClick = onOtherOptionsClick, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + } + } + }, + footer = {}, + ) + + AsyncActionView( + async = state.loginWithClassicAction, + onErrorDismiss = { + state.eventSink(LoginWithClassicEvent.ClearError) + }, + onSuccess = { + // noop, the view will be closed + }, + progressDialog = { + // The button is showing the progress + } + ) + LoginModeView( + loginMode = state.loginMode, + onClearError = { + state.eventSink(LoginWithClassicEvent.ClearError) + }, + onLearnMoreClick = onLearnMoreClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) +} + +@PreviewsDayNight +@Composable +internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicStateProvider::class) state: LoginWithClassicState) = ElementPreview { + LoginWithClassicView( + state = state, + onOtherOptionsClick = {}, + onOidcDetails = {}, + onNeedLoginPassword = {}, + onLearnMoreClick = {}, + onCreateAccountContinue = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt new file mode 100644 index 0000000000..a8b86ec1bf --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt @@ -0,0 +1,12 @@ +/* + * 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.impl.screens.classic.missingkeybackup + +sealed interface MissingKeyBackupEvent { + data object OnResume : MissingKeyBackupEvent +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt new file mode 100644 index 0000000000..45c16e7cde --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt @@ -0,0 +1,70 @@ +/* + * 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.impl.screens.classic.missingkeybackup + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +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.BuildConfig +import io.element.android.libraries.architecture.callback +import timber.log.Timber + +@ContributesNode(AppScope::class) +@AssistedInject +class MissingKeyBackupNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: MissingKeyBackupPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateBack() + } + + private val callback: Callback = callback() + + /** + * Open Element Classic application. + */ + private fun openClassic(context: Context) { + context.packageManager.getLaunchIntentForPackage( + BuildConfig.elementClassicPackage, + )?.let { intent -> + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + // Should not happen, Element Classic must be installed for this screen to be displayed. + Timber.e(e, "Element Classic app not found, cannot open it.") + } + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + MissingKeyBackupView( + state = state, + onBackClick = callback::navigateBack, + onOpenClassicClick = { + openClassic(context) + }, + modifier = modifier, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt new file mode 100644 index 0000000000..7b8ea7e633 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt @@ -0,0 +1,45 @@ +/* + * 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.impl.screens.classic.missingkeybackup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta + +@Inject +class MissingKeyBackupPresenter( + private val buildMeta: BuildMeta, + private val elementClassicConnection: ElementClassicConnection, +) : Presenter { + @Composable + override fun present(): MissingKeyBackupState { + var resumeCounter by remember { mutableIntStateOf(0) } + fun handleEvent(event: MissingKeyBackupEvent) { + when (event) { + MissingKeyBackupEvent.OnResume -> { + resumeCounter++ + if (resumeCounter > 1) { + // The user has returned to this screen, we can assume they have gone to the backup flow and are now back here + elementClassicConnection.requestSession() + } + } + } + } + + return MissingKeyBackupState( + appName = buildMeta.applicationName, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt new file mode 100644 index 0000000000..78d3d81c72 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.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.features.login.impl.screens.classic.missingkeybackup + +data class MissingKeyBackupState( + val appName: String, + val eventSink: (MissingKeyBackupEvent) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt new file mode 100644 index 0000000000..85d1042985 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.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.login.impl.screens.classic.missingkeybackup + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class MissingKeyBackupStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMissingKeyBackupState(), + // Add other state here + ) +} + +fun aMissingKeyBackupState( + appName: String = "AppName", + eventSink: (MissingKeyBackupEvent) -> Unit = {}, +) = MissingKeyBackupState( + appName = appName, + eventSink = eventSink +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt new file mode 100644 index 0000000000..67865ffcb8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.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.login.impl.screens.classic.missingkeybackup + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun MissingKeyBackupView( + state: MissingKeyBackupState, + onBackClick: () -> Unit, + onOpenClassicClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + state.eventSink.invoke(MissingKeyBackupEvent.OnResume) + } + } + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + title = stringResource(id = R.string.screen_missing_key_backup_title, state.appName), + content = { Content(state) }, + buttons = { + Buttons( + onOpenClassicClick = onOpenClassicClick, + ) + } + ) +} + +@Composable +private fun Content( + state: MissingKeyBackupState, +) { + NumberedListOrganism( + modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_1)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_2_android)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_3_android)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_4)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_5, state.appName)), + ), + ) +} + +@Composable +private fun ColumnScope.Buttons( + onOpenClassicClick: () -> Unit, +) { + Button( + text = stringResource(id = R.string.screen_missing_key_backup_open_element_classic), + modifier = Modifier.fillMaxWidth(), + onClick = onOpenClassicClick, + ) +} + +@PreviewsDayNight +@Composable +internal fun MissingKeyBackupViewPreview(@PreviewParameter(MissingKeyBackupStateProvider::class) state: MissingKeyBackupState) = ElementPreview { + MissingKeyBackupView( + state = state, + onBackClick = {}, + onOpenClassicClick = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt new file mode 100644 index 0000000000..adb8c2d728 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.root + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode + +@ContributesNode(AppScope::class) +@AssistedInject +class RootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + RootView(modifier) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt new file mode 100644 index 0000000000..f1ca4b048a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt @@ -0,0 +1,41 @@ +/* + * 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.impl.screens.classic.root + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.utils.DelayedVisibility +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun RootView( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + DelayedVisibility( + duration = 100.milliseconds, + ) { + CircularProgressIndicator() + } + } +} + +@PreviewsDayNight +@Composable +internal fun RootViewPreview() = ElementPreview { + RootView() +} 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 c38da7b11c..bf06613830 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 @@ -48,6 +48,7 @@ class ConfirmAccountProviderPresenter( loginHelper.submit( isAccountCreation = params.isAccountCreation, homeserverUrl = accountProvider.url, + resolvedHomeserverUrl = null, loginHint = null, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt index c6ce16141d..853b8a7423 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -17,14 +17,23 @@ 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.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs @ContributesNode(AppScope::class) @AssistedInject class LoginPasswordNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: LoginPasswordPresenter, + presenterFactory: LoginPasswordPresenter.Factory, ) : Node(buildContext, plugins = plugins) { + data class Inputs( + val initialLogin: String, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.initialLogin) + @Composable override fun View(modifier: Modifier) { val state = presenter.present() 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 b1ddc6e5b8..f26f342a42 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 @@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import dev.zacsweers.metro.Inject +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.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -25,11 +27,18 @@ import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -@Inject +@AssistedInject class LoginPasswordPresenter( + @Assisted + private val initialLogin: String, private val authenticationService: MatrixAuthenticationService, private val accountProviderDataSource: AccountProviderDataSource, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(initialLogin: String): LoginPasswordPresenter + } + @Composable override fun present(): LoginPasswordState { val localCoroutineScope = rememberCoroutineScope() @@ -38,7 +47,12 @@ class LoginPasswordPresenter( } val formState = rememberSaveable { - mutableStateOf(LoginFormState.Default) + mutableStateOf( + LoginFormState( + login = initialLogin, + password = "", + ) + ) } val accountProvider by accountProviderDataSource.flow.collectAsState() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 1ded677c13..030d65eae9 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -48,6 +48,7 @@ class OnBoardingNode( data class Params( val accountProvider: String?, val loginHint: String?, + val showBackButton: Boolean, ) : NodeInputs private val callback: Callback = callback() @@ -61,6 +62,7 @@ class OnBoardingNode( override fun View(modifier: Modifier) { val state = presenter.present() val context = LocalContext.current + OnBoardingView( state = state, modifier = modifier, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 741f65234e..60fa34f4d0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -26,7 +26,6 @@ import io.element.android.features.enterprise.api.canConnectToAnyHomeserver 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.login.LoginHelper -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta @@ -45,7 +44,6 @@ class OnBoardingPresenter( private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider, private val sessionStore: SessionStore, private val accountProviderDataSource: AccountProviderDataSource, - private val loginWithClassicPresenter: Presenter, ) : Presenter { @AssistedFactory interface Factory { @@ -101,8 +99,6 @@ class OnBoardingPresenter( val loginMode by loginHelper.collectLoginMode() - val loginWithClassicState = loginWithClassicPresenter.present() - fun handleEvent(event: OnBoardingEvents) { when (event) { is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch { @@ -111,6 +107,7 @@ class OnBoardingPresenter( loginHelper.submit( isAccountCreation = false, homeserverUrl = event.defaultAccountProvider, + resolvedHomeserverUrl = null, loginHint = params.loginHint?.takeIf { forcedAccountProvider == null }, ) } @@ -127,6 +124,7 @@ class OnBoardingPresenter( return OnBoardingState( isAddingAccount = isAddingAccount, + showBackButton = params.showBackButton, productionApplicationName = buildMeta.productionApplicationName, defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, @@ -136,7 +134,6 @@ class OnBoardingPresenter( loginMode = loginMode, version = buildMeta.versionName, onBoardingLogoResId = onBoardingLogoResId, - loginWithClassicState = loginWithClassicState, eventSink = ::handleEvent, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index 703120b260..a1c49e0d45 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -10,11 +10,11 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import io.element.android.features.login.impl.login.LoginMode -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.AsyncData data class OnBoardingState( val isAddingAccount: Boolean, + val showBackButton: Boolean, val productionApplicationName: String, val defaultAccountProvider: String?, val mustChooseAccountProvider: Boolean, @@ -25,7 +25,6 @@ data class OnBoardingState( @DrawableRes val onBoardingLogoResId: Int?, val loginMode: AsyncData, - val loginWithClassicState: LoginWithClassicState, val eventSink: (OnBoardingEvents) -> Unit, ) { val submitEnabled: Boolean diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index 76f8eb3513..ec76404686 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -11,8 +11,6 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.login.impl.login.LoginMode -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState -import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.R @@ -31,11 +29,15 @@ open class OnBoardingStateProvider : PreviewParameterProvider { canLoginWithQrCode = true, canCreateAccount = true, ), + anOnBoardingState( + showBackButton = true, + ), ) } fun anOnBoardingState( isAddingAccount: Boolean = false, + showBackButton: Boolean = false, productionApplicationName: String = "Element", defaultAccountProvider: String? = null, mustChooseAccountProvider: Boolean = false, @@ -46,10 +48,10 @@ fun anOnBoardingState( @DrawableRes customLogoResId: Int? = null, loginMode: AsyncData = AsyncData.Uninitialized, - loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(), eventSink: (OnBoardingEvents) -> Unit = {}, ) = OnBoardingState( isAddingAccount = isAddingAccount, + showBackButton = showBackButton, productionApplicationName = productionApplicationName, defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, @@ -59,6 +61,5 @@ fun anOnBoardingState( version = version, loginMode = loginMode, onBoardingLogoResId = customLogoResId, - loginWithClassicState = loginWithClassicState, eventSink = eventSink, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index d590f1fec8..6549e21e41 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -31,15 +31,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect 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.login.LoginModeView -import io.element.android.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent -import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize @@ -47,11 +42,11 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.async.AsyncActionView -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.designsystem.theme.components.Button +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.IconSource import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @@ -114,45 +109,9 @@ fun OnBoardingView( state = state, loginView = loginView, buttons = buttons, + onBackClick = onBackClick, ) } - - LoginWithElementClassicView( - state = state.loginWithClassicState, - ) -} - -@Composable -private fun LoginWithElementClassicView( - state: LoginWithClassicState, -) { - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - state.eventSink(LoginWithClassicEvent.RefreshData) - } - AsyncActionView( - async = state.loginWithClassicAction, - confirmationDialog = { confirming -> - when (confirming) { - is ConfirmingLoginWithElementClassic -> { - // TODO i18n - ConfirmationDialog( - title = "Sign in with Element Classic", - content = "You are signing in as ${confirming.userId} on Element Classic." + - " Your existing session on Element Classic will not be signed out. Do you want to continue?", - submitText = stringResource(CommonStrings.action_continue), - onSubmitClick = { state.eventSink(LoginWithClassicEvent.DoLoginWithClassic) }, - onDismiss = { state.eventSink(LoginWithClassicEvent.CloseDialog) }, - ) - } - } - }, - onErrorDismiss = { - state.eventSink(LoginWithClassicEvent.CloseDialog) - }, - onSuccess = { - // noop, the view will be closed - } - ) } @Composable @@ -160,18 +119,36 @@ private fun AddFirstAccountScaffold( state: OnBoardingState, loginView: @Composable () -> Unit, buttons: @Composable () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { OnBoardingPage( modifier = modifier, renderBackground = state.onBoardingLogoResId == null, content = { - if (state.onBoardingLogoResId != null) { - OnBoardingLogo( - onBoardingLogoResId = state.onBoardingLogoResId, - ) - } else { - OnBoardingContent(state = state) + Box( + modifier = Modifier.fillMaxSize(), + ) { + if (state.onBoardingLogoResId != null) { + OnBoardingLogo( + onBoardingLogoResId = state.onBoardingLogoResId, + ) + } else { + OnBoardingContent(state = state) + } + if (state.showBackButton) { + // Add icon button to "navigate back" + IconButton( + onClick = onBackClick, + modifier = Modifier + .align(Alignment.TopEnd), + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + ) + } + } } loginView() }, @@ -283,18 +260,6 @@ private fun OnBoardingButtons( } else { CommonStrings.action_continue } - if (state.loginWithClassicState.canLoginWithClassic) { - Button( - text = "Sign in with Element Classic", - leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), - onClick = { - state.loginWithClassicState.eventSink( - LoginWithClassicEvent.StartLoginWithClassic - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - } if (state.canLoginWithQrCode) { Button( text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt deleted file mode 100644 index d0ebbb336f..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt +++ /dev/null @@ -1,260 +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.login.impl.screens.onboarding.classic - -import android.content.ComponentName -import android.content.Context -import android.content.Context.BIND_AUTO_CREATE -import android.content.Intent -import android.content.ServiceConnection -import android.os.Bundle -import android.os.Handler -import android.os.IBinder -import android.os.Message -import android.os.Messenger -import android.os.RemoteException -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.login.impl.BuildConfig -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.di.annotations.ApplicationContext -import io.element.android.libraries.matrix.api.core.UserId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import timber.log.Timber - -interface ElementClassicConnection { - fun start() - fun stop() - fun requestData() - val stateFlow: StateFlow -} - -sealed interface ElementClassicConnectionState { - object Idle : ElementClassicConnectionState - object ElementClassicNotFound : ElementClassicConnectionState - object ElementClassicReadyNoSession : ElementClassicConnectionState - data class ElementClassicReady( - val userId: UserId, - val secrets: String, - ) : ElementClassicConnectionState - - data class Error(val error: String) : ElementClassicConnectionState -} - -private val loggerTag = LoggerTag("ECConnection") - -@ContributesBinding(AppScope::class) -class DefaultElementClassicConnection( - @ApplicationContext - private val context: Context, - @AppCoroutineScope - private val coroutineScope: CoroutineScope, -) : ElementClassicConnection { - // Messenger for communicating with the service. - private var messenger: Messenger? = null - - // Target we publish for external service to send messages to IncomingHandler. - private val incomingMessenger: Messenger = Messenger(IncomingHandler()) - - // Flag indicating whether we have called bind on the service. - private var bound: Boolean = false - - /** - * Class for interacting with the main interface of the service. - */ - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - Timber.tag(loggerTag.value).d("onServiceConnected") - // This is called when the connection with the service has been - // established, giving us the object we can use to - // interact with the service. We are communicating with the - // service using a Messenger, so here we get a client-side - // representation of that from the raw IBinder object. - messenger = Messenger(service) - bound = true - // Request the data as soon as possible - requestData() - } - - override fun onServiceDisconnected(className: ComponentName) { - Timber.tag(loggerTag.value).d("onServiceDisconnected") - // This is called when the connection with the service has been - // unexpectedly disconnected—that is, its process crashed. - messenger = null - bound = false - } - } - - override fun start() { - Timber.tag(loggerTag.value).w("start()") - coroutineScope.launch { - // Establish a connection with the service. We use an explicit - // class name because there is no reason to be able to let other - // applications replace our component. - try { - val intentService = Intent() - intentService.setComponent(getElementClassicComponent()) - if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { - Timber.tag(loggerTag.value).d("Binding returned true") - } else { - // This happens when the app is not installed - Timber.tag(loggerTag.value).d("Binding returned false") - mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) - } - } catch (e: SecurityException) { - Timber.tag(loggerTag.value).e(e, "Can't bind to Service") - mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) - } - } - } - - override fun stop() { - Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") - if (bound) { - // Detach our existing connection. - context.unbindService(serviceConnection) - bound = false - } - coroutineScope.launch { - mutableStateFlow.emit(ElementClassicConnectionState.Idle) - } - } - - override fun requestData() { - Timber.tag(loggerTag.value).w("requestData()") - coroutineScope.launch { - val finalMessenger = messenger - if (finalMessenger == null) { - Timber.tag(loggerTag.value).w("The messenger is null, can't request data") - mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data")) - } else { - try { - // Get the data - val msg = Message.obtain(null, MSG_GET_DATA) - msg.replyTo = incomingMessenger - finalMessenger.send(msg) - } catch (e: RemoteException) { - // In this case the service has crashed before we could even - // do anything with it; we can count on soon being - // disconnected (and then reconnected if it can be restarted) - // so there is no need to do anything here. - Timber.tag(loggerTag.value).e(e, "RemoteException") - mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) - } - } - } - } - - private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) - override val stateFlow = mutableStateFlow.asStateFlow() - - /** - * Handler of incoming messages from service. - */ - @Suppress("DEPRECATION") - inner class IncomingHandler : Handler() { - override fun handleMessage(msg: Message) { - Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}") - when (msg.what) { - MSG_GET_DATA -> { - // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied - val state = msg.data.toElementClassicConnectionState() - emitElementClassicState(state) - } - else -> { - super.handleMessage(msg) - } - } - } - } - - private fun emitElementClassicState(state: ElementClassicConnectionState) = coroutineScope.launch { - when (state) { - is ElementClassicConnectionState.Error -> { - Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error) - mutableStateFlow.emit(state) - } - is ElementClassicConnectionState.ElementClassicReady -> { - Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId) - mutableStateFlow.emit(state) - } - ElementClassicConnectionState.ElementClassicReadyNoSession -> { - Timber.tag(loggerTag.value).d("Received no session from Element Classic") - mutableStateFlow.emit(state) - } - else -> { - // Should not happen - Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state) - mutableStateFlow.emit(ElementClassicConnectionState.Idle) - } - } - } - - private fun getElementClassicComponent() = ComponentName( - BuildConfig.elementClassicPackage, - ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, - ) - - private fun Bundle?.toElementClassicConnectionState(): ElementClassicConnectionState { - return if (this == null) { - ElementClassicConnectionState.Error("No data received from Element Classic") - } else { - val error = getString(KEY_ERROR_STR) - if (error != null) { - ElementClassicConnectionState.Error(error) - } else { - val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId) - if (userId != null) { - val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } - if (secrets == null) { - ElementClassicConnectionState.Error("No secrets received from Element Classic") - } else { - ElementClassicConnectionState.ElementClassicReady(userId, secrets) - } - } else { - ElementClassicConnectionState.ElementClassicReadyNoSession - } - } - } - } - - // Everything in this companion object must match what is defined in Element Classic - private companion object { - const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService" - - // Command to the service to get the data. - const val MSG_GET_DATA = 1 - - // Keys for the bundle returned from the service - const val KEY_ERROR_STR = "error" - const val KEY_USER_ID_STR = "userId" - - /** - * Key to extract the secrets from the bundle, as a Json string. - * Json will have this format: - * { - * "cross_signing" : { - * "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o", - * "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms", - * "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM" - * }, - * "backup" : { - * "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2", - * "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc", - * "backup_version" : "1" - * } - * } - */ - const val KEY_SECRETS_STR = "secrets" - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt deleted file mode 100644 index ef352794cb..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt +++ /dev/null @@ -1,103 +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.login.impl.screens.onboarding.classic - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import dev.zacsweers.metro.Inject -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.api.toUserListFlow -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Inject -class LoginWithClassicPresenter( - private val elementClassicConnection: ElementClassicConnection, - private val sessionStore: SessionStore, - private val featureFlagService: FeatureFlagService, -) : Presenter { - @Composable - override fun present(): LoginWithClassicState { - val coroutineScope = rememberCoroutineScope() - - val isSignInWithClassicEnabled by remember { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic) - }.collectAsState(initial = false) - - if (isSignInWithClassicEnabled) { - DisposableEffect(Unit) { - elementClassicConnection.start() - onDispose { - elementClassicConnection.stop() - } - } - } - - val state by elementClassicConnection.stateFlow.collectAsState() - val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } - - val existingSession by remember { - sessionStore.sessionsFlow().toUserListFlow() - }.collectAsState(emptyList()) - - val canLoginWithClassic by remember { - derivedStateOf { - when (val finalState = state) { - is ElementClassicConnectionState.ElementClassicReady -> { - // Ensure there is no existing session with the same Id. - finalState.userId.value !in existingSession && isSignInWithClassicEnabled - } - else -> false - } - } - } - - fun handleEvent(event: LoginWithClassicEvent) { - when (event) { - LoginWithClassicEvent.RefreshData -> { - elementClassicConnection.requestData() - } - LoginWithClassicEvent.StartLoginWithClassic -> { - val currentState = elementClassicConnection.stateFlow.value - if (currentState is ElementClassicConnectionState.ElementClassicReady) { - loginWithClassicAction.value = ConfirmingLoginWithElementClassic( - userId = currentState.userId, - ) - } else { - loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready")) - } - } - LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch { - // TODO Implement real login logic here - loginWithClassicAction.value = AsyncAction.Loading - delay(1000) - loginWithClassicAction.value = AsyncAction.Success(Unit) - } - LoginWithClassicEvent.CloseDialog -> { - loginWithClassicAction.value = AsyncAction.Uninitialized - } - } - } - - return LoginWithClassicState( - canLoginWithClassic = canLoginWithClassic, - loginWithClassicAction = loginWithClassicAction.value, - eventSink = ::handleEvent, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt deleted file mode 100644 index d2706fc24a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt +++ /dev/null @@ -1,16 +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.login.impl.screens.onboarding.classic - -import io.element.android.libraries.architecture.AsyncAction - -data class LoginWithClassicState( - val canLoginWithClassic: Boolean, - val loginWithClassicAction: AsyncAction, - val eventSink: (LoginWithClassicEvent) -> Unit, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt deleted file mode 100644 index 73f68e5d61..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt +++ /dev/null @@ -1,20 +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.login.impl.screens.onboarding.classic - -import io.element.android.libraries.architecture.AsyncAction - -fun aLoginWithClassicState( - canLoginWithClassic: Boolean = false, - loginWithClassicAction: AsyncAction = AsyncAction.Uninitialized, - eventSink: (LoginWithClassicEvent) -> Unit = {}, -) = LoginWithClassicState( - canLoginWithClassic = canLoginWithClassic, - loginWithClassicAction = loginWithClassicAction, - eventSink = eventSink, -) diff --git a/features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png b/features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..67684ee9449ce2261c34af5ecd2bf8f91c61077d GIT binary patch literal 13888 zcmV-GHowVYQe$`JuJ~aXL);9qJv_3KsXdj8hh`+wr#H?(cowxtejl^$?d zuRyiC#IgU65hIeIO_X8`BWe?_FXirebnsSQAhL+IZtFaV8WGt@fzmn~R41iyjJ z!EZp@uyboyqcPG$)nb zlWC2Yg`?wG&`n-g1(x&)$=DoJT_wzRma7Iq&Z^d+M#nl_8_cR!q21MjKphJfgT+?d zPO{^T4J@u7Y3p2n$;;GIUsgi&f@^Q=UFooLF>!h+B>Ip92D=-hZloN8eZ_bX7@H3W z*Gqa$MhD?$hp7)XO1a0W5ytE3Lr0|zX4l$aZnYgcs%_Zjs!lp$3r1Mq0C&yXk9p;K zcp0^qg%EFg=E0rVYi)tBYBo$XWnoEvylbGR))~}vlkv((PXWY{tAqu$+0Zqs6WY9MX2tQtTmxmX=91%<48Tj&UJ@aGbK9m~8afD1UEI#b zFbs_kldBw!WTCuTx0oZ7WOnHedvExr2SrFuM$~>pq|tiipjdYESpLJ@S_d4_)(LZJ z?c5EYURMQtNrd^L3h}zFo4Q6j;7VfGN@6FLW$AV__Ti4Py-?(?&~Y3>%Rw@)$oGgU z#hjcXQa2I=N6nf8hu1oj(l}WC zuH%-hffup8XhQt*Q$N4l6>c>R*yX=(AKwel2H};>F$;{VGl%F{iEa{#QLd4@(Ww=K zt6=zU(}`C1W=7k5dNMZoVM;GdJi7fbSWumqN3lH>*+=y4|{>Qdx7^A$GBcRES@A{O-#M0<88qUP+d)ZESald+4hu z;wo*7ORfDrwG{its-tU(13pow{B8t(J-Q1@)VwZQ zi!eDb+|V;gRuBc_a(QbCXr~m9neUZ&|Ab>GRyT3{P|9g??-_t%0XvssGxbFX)-CQl z9A=TL^?FK`gVld};(2S}5VV6X#LtoY#0qSnk9De+;M1dfoChu{u_gx=cwmpm&(@Oc z9WKd7EgGkxDH~Vs$t-o^=aw{0I>02f(%(_D4uhlVPo~>PxZtu6EPm|(9K?1|g?Q!G zTYIUzbshCOf1Hr#qr*F}L6wy}wn;pS=rhHY36R1t++nl%ac|9Kmtav7(*ux{0i0-o zT8^S#39KCdR46Xj4~H@`K=9f@A%l_*Z0FgXa_}z|GahMDk6Ke z|Gc9yf=?0p^d0+pOC_0_RowJlofYQsNJNmVL-MwGos>!0iegOVGt!TPyq4M|M6}n& z6s95q(3C{ROfGfGG$aRKf%H;KdEim&i5>Hyo$^akf}Jp4T=cP1m%U&?zF;B#<0BhZ z5#_6WS3d81W^7L^aWz|-i>yyc05%bW=AIbuY#alKT8}n<83DCo@nmre%(*Dt=JCYR zGq|Ic>72kd6Gv?rhzJNHjSD;G`FgDP?z@T-FZskN%lhC2v==1AzxM(}TUk1Zqq|RWSFBp#Kyd$XeEU4tMddsCzOyXGFIs)-n-AC`4_Jtw zeB_6hyV70dKR+|(t1o-CBK!q#ys#U@L(y4(qIE#8;8ONht=oA8mP zDenZ-TR7L0o^alfk8j6x&_uV3V>wZt<2&Xe3DWanCjr2V{^0-xc|bz^_%FY|94eLX zcoC5Gxo4?_78KFApv*nRNkObb8VfK9!^E_~EYt(EN+~9DlhNIQ3Qxjoer>D(@wNGN zSvX*q%H9Jrozk=?i=XPEU7Bn2-2GP)B+RGP5&AYzwpEw?)8b_Vu%B(eh4|N7ZmE~m z>L!{<*Y+{4e`ZLW^N8_>OEcclid2<3E z-E7mf$(=_y22Dtp(;6@+pef!~>2UY#1xV|a{`5fStbI#XFTP~Ib`aF|=i)22Ue`4` z0vlXWbnT%9+ijzJ!X%@1N@TLJr#Xpc;!75m-2sDgZO#WL0W`)t>#rb=>7+bH@Uk@H z#@H1{;N!6vj!CeXU6s>rl|A1ez<0!wTxN0_Hznl}zGxA@WcvIzlYxChZc%3!&T`f6 z=f^tN(%DO3KiYm-;^kXzSxbysIYttDjPgM8<9Q^?+0~+%I3izV<}SAqb8XJEjW&YpV=95ZV!?4h;Dy~Q|q1$%VgbMWx+bM(Fwo*I1~GV6~qTKW=U$CM-k zE?Eqhdl{fOU3`Wn;3A3>w~I8*$N1rB|9+u)amNCvhI`zH|Lv7;y$WX7W-P=%`^Ak^ z%yw4>%^BY58pxHt7AWH*89#^0Fb~t(V;nULo zG=Z-E-A}>yw?7R1&p(yLCcB21mGk%laj9A`f@q!2TC$m2X5=oKx_u6$?`gfwW_3@%H13 z-x!2>^Y;IAUwq=;(65Xy-a(`khK#lk8jck^<-Wd1!bJody2JcSai{$5;u>Ws6&-r_ z=bs+k3mqM8Sj9@W+Y77cq7P3ur)e$m()+K!9LvU4elO?aBfDW-HZRF^k~Fa?ZZ)S9 zChmk|5%yUZn;<@?5=nJ4s7`<1S@JGQnCHR^Z3NEX3na*!p8b^&D{m8Os3|PO%M6Y| z%M&bJjDNN~sDo-U1z=OE=)7iE+F&6S%tL?9E&Iw@m-N9DZAwdg_onOW7QtDm?Bo^OolT-1N3V1Y_<*jO9{p$Z2kvWtsOUTT>tfdQ9?xl(O_z~}kse!${DdA@8gB1Kz!kmq4 z&*j@4_>8z9u)8Yg<=lD4xi6gYHaM8gE5`dCyb+!pc^;<;hH?qD@9$ji5U54P;x zo-D~_^GpSLXx`^&!@O&nLr%L+RCT> zb=X5zI9xQiW0%sRG!fOt=ue^rifpp;(FcL%?+}5UZV$Beg6O^>{=Wi((YbELC6vwjuFLEE#n&h+qp{I@l0p0kdh@YIe14KEcb&>FAnPG`F zdx@wG{<8-&4Yk6oVe8xDIvf)z&}+iAvdV5jsIEPtQkLg&8lTL#)@_;9dhOzbj`?{0 zyyM-96V8EGl2G@j?QV?1#rv{Z!&8t-=(436x=Z#=yPE|`D3`-5X%3GY1SG?+>AF8a%7 zE{EUS^feObn9kLiG%7sAV`83b60*yJOUadS?`;fIjQle2E4*~L2gX7Ls=d-Se|KZ| z6#%^WZrcP?y5Nqr^;(q@dumT;N|CNk>v1hQK;u98b49AZ2C5^Z@Cb35&8TKVfHck; zviMDYXQ?24yI->f*g>ns z0pq>p9`90l3ncEUwc+BvC6{H^SZyYH;h!zPk_u`)-ko@;TLv2lLh(JiLL65a4$-^g z^QAz4|5qlvbAGEnhWN;NpZ~8A1Qzb+YePReMk#%Oyvz8&$3x@^<1g%(>lV(M>rU!i z5MtR-F^a>*xC{OJaT2Y14?M8v8MuYwDBTOEbWUHgJCTGvG(G}9+qI2pv$O}`9Ns0r z4?E3WdKccMc_Tap7v~DRKh~#ke%_-rN@u0br)cesvGVCBzIel(mc~p2dN!=> zsr^~UkC)T^4@BS4mj1K6+(`2bEJ&g9k-RX6Mdl~r{`f&$6UJnSfVeb+)b zBfOs&nA@uT%fOxRuLE~5e89}5h@=?)fS{KD^6ibJa8} zwpzNqNa)EOZ|raZ0}+*^-48UFaVGba^VtM)e!_rd2Mzt_;r zfz%m@uR{K-`UUV;i(fZwpSKM?4i|6wCNyJk(7wV5c^JS1c6e-;cFfs^8H|T!y2B^` zr1P<}=5TsORh$Q2`=@<+T?T9jg|6`T>}-tEI#*l^o3MchCbU#zVueSWTDW*`c)C{u z2tr(kLpwoqZ&+|5)i+-UZy<|1koK|qYvIe=9zbtF$x0I4qCA1lf7p6EtsZpX2Nu3+ zN{jTgTrmHHAWTq!l$fubHnq5d6Votgx&KE>kHU|%YT^tMZjhEgPobi-TJv--r9*vs ze?>o>w&8R2qAYu<%mX8h@qiA!&-O)Z{!E<}B_H>#^u`mi2>&H~E-@a7_k?f$-je;h zeD-~_21d&I-}snL z)Fok*kXd9DFk-aMf(Hvkpa?cuB>MB=v3u?p#WHabRqJnl^}F0Z|JFrhK^<@~+GA8E zy!F`uCpN4NlhnYReCmE*}oqrHVo-ZBYPa z^b+o~13x1qIx=P7m!5Ez%$4UA1%+Xy#`N#U6$Y^&n4io)=BNjA;*PaX{f>HFaHk>% zIzBeu2=gh6q8F?XDQfnRc%65ng)wc_Trq1Fgx5k0K3v?9xC3MMDnKO$#5Dv<4d+OJ zbB~5w&i`XrG0)!|(+w_F{?o+>nzgT?Ulr;SQ z(Wk;^2kw^Ylh*TNS&mU=93Z4pQba5DIUj_$=nY(F<^|W7ZlDU-kQYwVWj;3grK(1W zy>xhe5F#O-9^dz9aHVCQ8psP(r!~DuPW0Mp!%cr(J)pRzjQ9CnMg5P0dksy$h;;lU-3)0Y@9r1rQ&}l3zkM9l8-$HLP!fP z(8Ypvm~@4QEMjpPM3ro%MrNQ|1c|^B}Aw8+A{PZvej#6O<#EmqM84E=9{O~aNF)1dJ5is*Ee8r?~t{fi~`U2Qkj>E z1?jqIAtuoeH>&#+hL`!)$oqCaHD%)Ol`-G}srl-LWglMu_VlIh&tGwVxW-%bR|hx4 zr?zYqXO@-PvOPB#xBS~H;?rd*EYSZA=h1J~e`X<$j8?m=m6A6CBTDr_mrK-_b7g|f zKGq;G)+I|>6U?jfQch!HFRh;gmv)~6GijfF_%8ft|M#-(J5h7pgoiz&#;FSkp~+h4 zgi4bh7m1fKmW4C-lLAfE=97RtzoW2xJB+v8ypIy(+ z5ObA`B}ahhE}7?G=#RCtFpp3u59NI5s$aLdt5R`Ay_DoWx~W*%C&wsiN86$7iQ8v^ zv9}A_**#}^H~OguHqc+67!pQA#6O@|ro0)VZxO&yCTsQd7EX55v19O(#i-74VMW75 zMuWd+CPMV3J>Xjd!!A6k8>hPdZO5Mm8=rnu5e-CG@6fKZL>wUy2_QOIfnu^hwn55P zp>t!E@tzu;JjVl?B51NkPZUh!f;-H@ulQFec_*5VF0cAZm{+h<;&W09WDpt3%y0328?GZ{lU%@UQ=IT-Y@OAv&qR zXg)TtI%~=}>V@tKCeq{?GDqD^3~CDUn4QuT87*NhtkZ_u=&idJtPg-IDKSS8+mnDF znBt7~%Z@3OJrrpNe&`jIy`GXw7f-*{esIqaeDvoxvAdB~EJ;*#i;fem;ejk@WATAX zoC!%6P57)#3gVaX7^XtpdqICcd3q~cc+BES>FwX~WL)X@cXZ8~J>?vw0;q^psF%Gb zVq`Hn2NOKOZlYFt!+vuD)fEC(Wmkn_O5Im#%93M85UTgL;-5qI9}2#~cgMKM$JL+n zWF+pmSlOo&m!9-0n67>Fo||xJY?%8iq>x@{1<2NIHNMGy11@k&^+@QC@tkS2WwszE zF2#Y!$}gvLf>=UivLE+Tn}0MZy`OpLc6I=z$b;_pU}vgEAPALiUhm=97raN9BWe{a z_H8O&5=0>_B+&J~ObP{bRVclf?>iSz6D!*RHfu*(UMesa#xsEy8kA$;4({f{qZdyb z3%~rxy>R2;FB~&ON?``nD6_*sz6RoI0GGDdr3S@GK*2B~9FH~KGTo;Ia92oS+JBeA z^iQ7J3V*oaD`a&$BdtTU+VRn!-30&k;B8KoM|pAfg5#!~=g`P7FU1KD1D!$7vZXRc z(W0;le8RlUijgY0SA?Q$)N3Sw1;L?;>B=iY45i}5WZ5?$IJ3*%#p@-Kq9*--WQ9}4 z3Ew{R;7=K8>IPGY&bV;t#d69i25#$n*|rjZWw$J z>Cc^g`0?TNeLJ5_j$ES)y>}nBfZXZSmbm%3Cs_ahkfxEc({yD>JW~iz+AA?#lN^u3 zf}e9Om}GO338sTIlmu0=h&1~qB2ph09@{#mX#2^wM`82!C$#b*W5$Ra;<0uUM1TZB2xhCSyKH|8bp;NUl|L4yo zkCXk=u||bpRaEEXEG;@2BM^JN1IQnaS9+aR$w1ZG#p?~{oK98x0P{I3s?h&$U-U|t zstxXW&bw34K9~-Kv}?o8HEvhRB`ieiHf+B?ploodqvqorx{0?y%1)dxP~D|U+G&?6 zN^cz%nPm5zsRiiU#wQ-}f!W!Ih1GkiD?}ZEJ+%|$$^?Yj_|g}bl+tc-p*&<2r7Bza zTronh%8vFKU!{W?4yxVEdO_bT2<)DFgnL`}8B@;lCr>^KF;ytV>V-C$U$ksndgB1; z*z1%kc!<72GEE361|d}dt6yfTo#ZT`I^eA5ifF^DhZAE~qd+|S@Z+X*F`KtP34^B>JsftAkGOtvLxa0_Dt+Qo6C#CRKLq83}` zbCe%RP@R%uFvnKOy*_xx8(_M2!`26avZC6NSy4%w;e{5>CydglyXTsF%Cky2QloPN z*uXj^9WFW~m8V*0s=~6>>V88pNi;8ve$btB#PL&3Z1DN#KubuhhzJYXXTvw&a#;|f zZGZXG>*3$-yVdExeE~+8PU9yd+av%7@`#{#5I;SwIN92(vQ+~~ay6JpF9-|XLM@m8LMl?QoSe|FbtSf7>!V6t?}M1^`)A`A@s-J> zVt#EFFcril0ZJGYj&LOey4t5sem6h+gbGl+GUC6y?qb(0K;QoL^%sZhi5XzrZ5Rk+ z*-SK4G`j?|bP>D-#)THXgssBhMHj^>Ru+U6XvBz~obiv}Xq_}ZZ45JVOiaw`rA;AZrG_i+JcLT z+dINPl$9mM%5e3??3uad=pY8?An(E5JD@&qa#yo+M9OTkIzE3!mMaCNG?u0t;+OK2 z(a;`&tK6(8SXWB~=n}dP&vA3I9rI(STeAs`9(K6<;XD3f%DHL#yE|^tLWcFGMzq*Yb>P z&WSQ7aDpJS$`M2$GmixL&JWH=fY&~F7Yyv$A;OPZmn$FWF-+AaO*EpLF?mmd0O;2} zJwwWI7^;-x<1i%FMh=XY3cq4#SZxPvBgXUhrx z<%Vy=(7s_;7G>PpxOL}tn66Ro>71$MdRlbbyu|NR{|%atQkyJeypFY9`(2CUroQU( zcm?Mic>?_K4?i>`0eU61q6m@9EO)U6>d_M+!)VPdwKk1TXMS&$F& zZ;W>I*NVpI#%iq%k+T2rF9!toq5t@SR81AJseK1@BpH(?uQoyEwLkYiZ~hAOJ@zoJ zIOF_a4uiXP!l&=~u}7LwLs_qoEOFno%sJ=ig{0lvV@*}Je@HqZt{Kx~Wm#1zRhiIw z7C8rLIWZlU>p$_j--S=U{;e?6_P>AhEf^fyE^#{63!J><<2y-f0kZ0lYg!)F7_Q36 zKlJ1VVD(i)HCVlF2v)yu7ai?_vI6`w9C0ZhQ;7)EKqc=3jEut=goSBkB<$MzZij0( z-wrB#@pUd@1#ue#Lp!K6wGX;FIwxh0w}$H<+QbzahW~M4niFsd{<^(QP?(F$lVY(B zZTU`VmJ(yk_i^cor?@Y^^*vCZKfU#7`}?2XjMx8ib5tBNfRq)rK;bmK;3NY1p*6h^ zqZ5UgqdU!y3f_`J$HI@?)`a(c^tv3!$(CzTjlm?K5UY6#YU18XEt8Z|&EVhCauKoW z?w-K#sOB2*(`p3b=4XEGmM%JVQU)zu^a?f_gakf>>kb5nR8UkqtH?0D6)^$b*kw_x z#QFIhv+?rtE^-&2dXDp!zd!Ao`|g0%Ke>^IjHPOhae{#3B6ZA>RbqafF;-2*bw-9o zedJyn-UWxWWpflFb-0m^EDwWJO1({XbEq~6ZQ9~0xw=_U?RREaW2Ow-L1z`{#z>xj z>l^qLOfAGyJ&MJl(R~0aC(f@RI(vCu8f~s0PB_0|GYFlm-F44Df~uT?W`hvp= zv>fSryHAC4j#)Tu>d>lfe)`w&-W#t0FvUH#0Ie}I6~anUCS9$O9HT5_#FA7f-ln$% zehZeUK(){J%$tYD%l_~yS6(}6{1`uit#Fl~F-Z4)#M2O^0#Sg`&jF+&IvVLAZ#qcz z*l_K}!C%32?ef=L1Z(G9f7qoY}PcD6%^L-r5cFpEH;3Gfy zhNMi<`gqrXrYDg$$!M4d1~I4CaFtn}vzQ~?INk{IY5e2bK7X1YsQIULeMGf~d<{PQy{|>uk@wl6?D>H*{8QtyN2!6#7f{+n;WT$E<*}Or0kblnpua-iy?ny* zP2>6rLL|hqk*pAXJuYbj=QLXLL(BA1wF-5Ajorf%p3PIHf;?%Z*Ql16>t#%U^}o1x zS_ZD_eLKalBNzlr?iBiMl$b8GObJAN4F3;Z}6dq|mE z$mxW4kuDaFJo_m;Y?L6%XEuY0S1#GQ!5gxl&sZaP%d*n8exeY|S?j5rT|nka0*wi@ zFgP&9g&%FnEhrNzyE1m2sQ?gnzv)xLt}NrmrfAjS+5-C?{bgX%bnUu7`g6Zh;<6%k zbmK~g0ATt!G`tsHXd9s1@3L=y5ia`Dzrf&*?f&_}q=cmi31w{EeaF9hU<)XwI3hc~ z8Lc`<-bAdi&>e-mP|zs2C{a*q@lWdpl&opuSjiCS3KdRSr{G!nJ#z(er zXPm-B(CUP_z70J0Y#6imq|;$0&Bwf}@7#bN|Nb`}{ec&r)+#ulVPvut5duVriU$9p0{4Z?vb-NiG6_jq?MG{WW_@H>SOEZ7=xsW`!ESH{xsNxoXGvfJ9pCPU2b+e z|1c}((dG(FADB$tsgUwPtq7!t1?6B~@{(#^yeckrQCmy`y!VR#J7on&+f}!HA3k!! zS2>XYn^i$fyY<_ObKeN7e)|$vKRhYO0M%{1yY&{gdD|fLJ@OFrZ+pxXA|Nf%Skp*k z=Pk}klMQ)q;t7K7OVuH*xF}n~S#O1n!pna>eD0gsznd{Jv3>tLR?*Sb;n$x!+oNpK zfDSUBY05iVig#u?0nK%3$6YKsIm>92{Krpf<{GeAgsCG@B;!E`EOHupr;j@QNlq`A@X%T~sem&TzcTKA3!esJ%AtG~V8 z&V9lU^ud|GwVAqkKV3cB3sRrEm)NKAP23eEt_N2j!9dOnz>z{ubUjXEgDf~ha(@5< zT|-xQ4L%G@Pd**$haUygH6J6FpK&f()E?OU#DG{orWmOj&Do=it~Oo6wTBe9SRpY1 zBwiOl4HpdZSZaa?yq(JKt+d_RrM}SM3Z8!`SMrm9iYq|Ji98vj{G`$F{S3g>*L|RQ z?h}RB|Ii35In{4fyC6IYU8`zmGD+En%#(AG>0J0qMI{9@T*GiCW~4MAeC9X?k-0oS zWy=McJqM<1U9&r3`I+a!it{dXyU0Cm{`COMO#~XjlgcG3rYewL2MV%u zjv{~YG-Y&qP6hAOtl2NA&`{^%F!DjnTCqbQ-P!TPw4;LI@Q8Ea={)xlxc8w6|FWBF z$&HoE>#kaC4WBz^Aylg_a|U4DtBDKFL^;G(Jtr4xeDnl2CkOPHx5OWR@RRg@H0)3F zV5aZkU%<6@Z-h-GP`}(2y_-06-3f+G!z;ZS_xX!CulMt;P&r_ZXJI+r2gw^7UN*sk zG#(}QZfrbE2fujZ;uh1KM2Nnv{JnMbx&o?{N)pJiK^9~Z&_rzBvlierLZxGIlIDAU zt07GoTtDJy*zloM`z=V@`kxpece@V;cJS}G`S`i+u0G27p3w&saRU8Tj_iwKoK)J( zvQnIYtdS@vLTpm($T!1?i71cS-VyZ#m%+N5*R`706hd6y<;tfnoDRS$dtQer z#B&G81oEWJ+)`wgoS(w)Qyh&a|3wo%_mJpe|54GJ~md) zI(arRwl`D?{OX3 z`ptL4(o@fXW9Lo(n7~Y1?)8Y1KXcUtS>b{a`-odvw}u(VgfZOR|avgE}M%0 zD7l6!F&bMz02WuV!Zs8_q`<2)@DaH0;Yt6M)5(SC33=7Rei*O3+xfiFk4COl(H#UT zxFlq44kdZjgjkU(nxs~R8c~@+i9BHi0;tGAHP{X9-GiUM_4|Q=y{DcD2d^RA(0^B= zo8LtMXi&M%A0w(1x;1d>kMr@lQtGMNZz^g7$d)#pr}V+DXLyY64L>Ha4!-&yYo?;S zGUdG1^$k%km-%1OfPEuWP;N*qkcr`?lajS2{E7vyIEPhgeTXxxe9$$8Y%&OUr}RHPg{vopyE) zJUZacIH`vyKF#@ZT(v4LAgG+69W?KoIx?$2Vi1XFH zG~Z6?3#L59U*ngYhDX`^AbcFY{)6vLN4tZWwB@~Bt_qu|+d8z>eA=Pt%B2!p=uT3R zh3Le|2*`!`j){A+RTLL+dF%E z$#wiVAhFr)h`tDr(hTX%rNA9wX<`IV&`LDjgH5zF5r)lQ8M2#e(>Pc^@)%fp+Sx&< zJ&R7B)*032GLAN%0_!hbGN%El?>MxoU8qkWBb@pAU|Vdj%SV z(k+AaeFHFqHgh5Rw)_pN>4sIokx<$Z70?!jtK(wpMCT0c>O)MlPkS12~J!Hq%!}> z)|5UVPE4(o7e1XB9t8;~m%;j5*TD?i{t40FaPjLmP>fg#HCl7*>|o#KlwcPri#{{G z8P+g^QT88$7Ja1;bxCr_2$#9U+u}8UOZ{j43X}5W%*jr#O~yOnOnpwI&+WoDr!m5lyCLv zlL(Nm-imFrUqx|n+J6YTxgD4geS7P9Jv7q|L|7NpY7iEYBdUYUtw65xVcJ>jYx&uJ zN(|EQ>R?jiZFxkoNiwI@N0JB~tR%o>dci2I*e^yUNpsD;3g)25fH%Pj`$odvnE+(4 zvJAd==Kvf)J8&WT_9hafLl)$$DmcI50qROyrWp`t?2Mbu{0lSz2tAm)HWiCpl7%J& z19HIDWVs`V7MS8}q)xqn9u|H#(~FofF|*9cjz>BBoBbj$=a2(fsayG zbhbk_8GH^gqB%7jh`W&h zCJIjgESGB<*~tIQ1dH6 z!(&oBhklyWCD8Z61sLf$Xsx%WPU^OS7}rhdhr@O%(Y47x42R9iIx)^nXg22y-&-M? z2gJ5E$Q3xFtJ%};AU0PXm(8S{pEQ~oY1m(=F9>6;F`6b1ZrZ+koVm}NSQ&} z;sa!3m^EvYE85Tb7`*!8w;C2Dk5_>;(D%SHj&|mKs~?_g`*|N z|E{T$s(#HdIi1b}DL|fTpfM#`*t%09C zvJPH?_R$ml5t{fL!M!@Vz!@U*?OLbR=?zLKc9+<%H_#9N}@fnKE} zx*epk{ zgs6?Ivx22Zv%LVEnnZqk4%bM6zCP=LQAHt!ONA0yV>04I18rI(V1=O#L6!~Ko){sH z|5WLtD>l;KRV2KFR&6;L?d2y#+jPJLWF@o_UZX z2|6pN8;}%M5aLr=VgGDXAv_4p@44+Km+pV1Vm%3N0A8l;QEZUz5qA$~<+Z`%M2{ALKORr@bFT!y_W&gD}8 O0000=#AzpVeZ=gT?9w6Ow0XpeBxT=%N^C5Ix@OM-$bi8w&5UR1aJnb2y z@T)X!a`3AJD{fjlFsp72S6vtkRdqsSqv1%J!F*FWP7+?C8+0%KqVJBr_&@}X|AgXu zfXZPB`MQJAc&$@~Y8|w~==OGZbj6cT8-ri+^h?Q{i5X{%w-&orI05RgIC96J@E|c^ z;;+fcE_CZjsB0fTHRaMY#wQ~Nxc01o|#oz5#8`|yk z*NVT}`5rN7X6A|*Ik+%dRc^!ww{D`p9{FWt)?XUtyx^N_ha-zDssjvH;D&>NLLV7y za$*v8O-w|FoW#HGWM;IK(kG)KGg7`ID2aIlNa;F1$6`JR#8Soadn+<=Uw?mQ+!_5d zpuevVX3UsTfHh?wiIZSWe}6UdmZzQeOQ8xpq?vQY*S<1bwW>2+a7*LQfh=m{mk^Pz z8yRr>&K)oj8FJUeF5!ek5HHL~*IbFH%$PQ1QC&GiJ!VQShrdgKM$9)7@~(rKkwItB z-^`gabKcxGNa*T1G3Pa1S6#B=>8EwzA(|d?%(-Ii+5y+!Jv)lX#TCF18Y&?(|XZ<#E71xZri>UcJAB>-!Z>ylAyZ`-;Rwr<^mgB+L* zjB#R^BeMXxb?BG9L&yrkNTeK4?PWnQoo;}Y?|t?QSHXiQJ!qKooUdFx97p(~NbI51?D1VYVawQ96yXz;!|G>- zyC|^%!(8FW><-ZI?Z`Jorb?K^NnS-%D3~(IS`@1md7~AdMRc;j%as z!|8W&fNvh#1UsYU&8TMV68U0RWb&W~L{(-DMkdSDm8;6~pjlP}9+U7k>Ow_gyi{#j zhjoROAE-Ut764;(6(}9peI%1fn3taibl^AZ#4-Wq95yE+>4J=;r2npRebvfCoks|UKX{n?R_=cIM)#9Z69Y=Q0Dw&oR^JP1C38OwrcfEX$`>r@n(2~)i3&@gJO zrERTm%XyZI02=psQUugkr#!2PEpyR>bNjhHXrW6RL#ee+o2+_5DXm7$Iqa~AqzkxD zo!GWZ-hcXwE`~!i9g@uX+Amx=9Ea($*u25?J2B*^5{;@y_bZ7C&$Z6-?wGRKlsNO&9c zeo9GYNa#5)iu&2n6@}i7(1~A8`_S*cv;&7&I;5C0q2-Rgoy#L=oRb<>cWc;q?>*Ty z!~iGK7L<`Vh=S2He;3Z<2UKGeObVC}5!iAMCuI@<05=6)9T{OxmN%J;a=BruiU5|8 zxjq)ziNTzI{aHgJA5>Gr@pU^ZBaMMg${{r-=UP+QlND_0iN0 z_S-|zb>p5=re$fvLIgXL36;xYwV^+*Gz}bnI9c_f<9%3p#gb*K-~mn#D042p`m%vJ z5Zyx%mL?)?i~h=O(LIDZA#)XvdLR%Y<^ZFU8PQw0Ml?`<%sAW)>gBvi4WMh*uc+(o z*aUAyCBda5hN;;wllB=TXC}efQ#?D>xlYfdd(LRfw%x(Hnm`$qs#F89`8)s{SxvWE zx|ej(IrEb)dRV@Y#QZ0gEL#Z=V0yrq^Cee*a4`7R+DN{^q-$;2xDm#;Z7J}XnDkjFP3`(g3putd9AIv_=V4|NIh|Eac zP&a@k8H?q>Xx_B`+2|A3R0HPedqULO>!eO;&cOVri#{xwkEscx&b60(a>=p|JOJqd zVa`AJ^aqEdPw?v4`GIIb8js3SV9Xv;_BJTDxsKx_P;&Zsv5(KoFMp?VvH zSy>$r=C%5&QzI~t0*>@SouSp=2MZql@Q9{;>1U^E`O`kRPkdN~}NGYhaK<#G(;%7Wg<7OlcUXiK5oRW!DsTMU`O;@Zr_x49t5!d5f*Y_i~+WaE#p*K z_NhL_zCIRr1w+t?TOVSDdKHKcW=(EaJSNu>yo?^#Ob!asUaU+plYtfewn{d7O7$fW zgYoK>2782(lTEt>!I?JBxU~REx0j&|5mXsH{>@piAY1g=xO48@YhLpDgXXk9Xv}%~ zl@~3qJgi9D5;os^FYJttbrGS|ltKjAYoOvA#L$2##snKAwg{A3#$aLB6gSM-)f7_? z{YpQ0y*ufc-mFSFgfrk~8GgXu1aZ#vO(cThtzsboKqipqcG5pQ`LsSK(gbY1w*OA) zQZhZrq_#fPG$vV6fT|UVL1)jOhb?n%#b;l7_DVPi>7X#@b3XnL%c}q@5|eEI#YWh* zZJVrUXkxvt6u%`5f7)VuW~EC z{IWkk;0$`ene%t9c=z(?fL@W9XvuzWw!LKj2Uk>F!8EQZ7opV9SWHV@&~TO;RC2Uv z!2Q8bj(#frw0vDv2_S^@1&wSUOWIi}qj{(F8z>Yo)e?WVTr1bXhNd?FQ9cLIAN`l28>$RycZX&>3&HmNn}sE#_PgSc`rtQbIrfn!2FBElnAb`F90Q)B~POC0!f!2aQWet(bAxTsSN;D7mp#+_3B~4+w)E z5avATBP*9jJ!nPh(Y8D9hB}FIn7(pf7$bDuj^Z>TxG2K`Sfw6wGNPcfTjM?%*^KD2 zKoh2EAXUxUC^C*^$cElpJmv~X0T%7`@hq0vT~&gkwo{vECM=Xqwus-tPF69a;LVS1n%+`#J4*<~;F3=barpd2#B-j%fT$Y~5~{#ZU(? z7~<={`Fa|x1aFx;eIZ1#;g$TB;SvO@6g{D-281b$WEn+AK^^kY(BH}thACxEt7kO& zXPHRuu~Adht<|`KE>JLTE;fK5q9bF5^Kt(y5U5+uLFs}51I-|PFQiD!G&34W=`X7W zp?%u7UbkWd_G8+w%z5Jb-!T;B@=d9+<6~Q3*VtxdO!`-6a1hieR&W3cHfmatOgzvSe(&Uo_ZvK9N~T&FhdhctBAih*4_b|7P> zQ#Cej%-4DOk99qEo78~fDJ2|4^4-x=A%j!;^f9-hM457YC(rCaV#?{Dgunp}=02t1 z6`8$W#yXv1uw@LBO%RDg^knBJb}FAJV&^_He43Ql&p?s-Y|VR_j6T#P)~PtD6f%MZ zGkFEsrOz~p?A!?o651y)-?2?~?n97+BtV{pVhq@A~FngflbR0ilQ!)-k|U=#l(c#y^I#7I}bmJFSP}Y_)8sJ210} zCHi7yz*%$Pm5a`RKR)&OFmKjum@fTfpgwNSMQN=x0SEtMGyDGRhdYanIyr`X0!H1fU(fRXkIlp?%VGx}l9 zBaWcFaM~^BUOWQ(koJi=hc0?;)HB<+r`GP;xCthueu32WCEOAWe#~wrf3k^xTU(u$O6{bk2#|t&M;VlhIwYYs=PrjlE>7 z=cIPyR$eAV@gcgQLrw-pJC@{tR|rjYHHV4Ct|I^vj}DJ1s5#7ypwgR--OlK#UGHUZ zJ=MLpjG0nG+2v85RSx!GJ+TIFtY-yFra@9+$#jCc zG1)jq)N_nyKy@f17>Vd&QiIvHL0pb?*F++uV;$8qohYstlcJ5v|B#H~_N;zr&6qLJ zb(5FDKIor&t#dy9-LF|5JH5D`?845w?=AEeR5cS8p-zZ8BmQ~Oe?auHlvkB?)~CFp zAp|4JRA-|HsP0;xcZ{~A=SsWsK-k{_N(VW>i+=lA@$cENzfwZs=N^9&{GVKAfP1!V zgsnTb!4&<_=+&$vvOXQW(ne?&f_MfJ%Bk*+^poGg(63QGj}egm8$wwRGda_fb=5px zc05QoWqYAJJ`Sxp>0mK8c=+!;W%Dn-`k&u|y-0h-oX1@F+QDc=Uy~ZN{bzUOb--Y( zRkSvpA%rUJ;$%iD7z4?JieojLgh;|rV9F%Ro{zpJg!INx3Z^d048YHV8VD)9>5M;w zqaSf3?C&%$>afGV^_0x0b0fNLxc5#N+rCxmS9(xC$e>}y<-THfX)n_nn^I zOMAtf^PYX|O|jB}@r`59-LaFH!XtVSLa}w}X{Lh^$qREJF??a=GE(GqXJ0d(D#5%m z9HT8x2KF)T27y3{4+hDD1Sn&^SDbPN9QkXHfCH80&pr%Je%$eJ`Uy{mEjzZs%^QBG z?Fwq$Lr{81xL^M5jh6$x3ndzcOb?mZUi*v8SWf{7Pve2*swijaWzjtd$}%u8ek!7; zd};elE~CGi>0Nhd({<}tLyxIv=KPHdU$H#Sw8g&Lh4H&L$_x|&7$#ED1qIqU{tcQ( zSq-L{xd7yt1WRb3SFGl!!;7E(9Qfi* zUx&>*w$dFZOo+6g$&=C2gKWJ3?Uz+e#DK)87BYN-=_6+i#TY2fDZdYCMv!1kh)Acv^B;I>s&cPKg8H`n56C$FG|NI^Zbyt`~86#5$ z3K2|%MRAaB*Chd1tO}xPd4ePmib26@Y8?n5H-9lH6di&=RNbJa49lYONsm1mp7I+{ zgoBk5lfLG6eh)TB5&xe*`fs64ISG{}C{3&`N{D9k!Au)saI3?E>IHPINpsEzQkDaY z=!(v)YX(p?$9@4 zh}b~=RFNG7y&9s*t0BUQwnaqgTz0Y4<#l0h52ZmvOL$(1EQrd6l~MA!P2L+6KrV+} zaUtO)&o~_pc6!#6o(7LS{1NbtTSsAhVqBO&#W84qeK3>XEG7tTO~7=%!BPuKS_ai6 zRkb1!5B7c_X=fO>P(R8^fvN&Z$a-P|TK)aW7dEqM_0Jqzd&?d>=l7U7AN|*hmqzij z!gnWO@)ujc=jAyssAR>b20wTcS+2-h5C)V0ff$_iN1G3g2G(WO@@6+mA-+KkD?!E_&zcKQ{moCCEJQVtBus}pLr{8t5-sjFpsDIMDXFOre*00@q z=bq4GkC}7cqGPU(9T=F{7#VZtID~?EJX(zfA&ofpN*qBvvLxA3O(n(SY}%<}vVQaU=j6I$m^m!=P9(mLzS$$>XESmy1c? zyA&VM*G|#%$85Xd2YX~3?GbZ6@*OW&8XLXTrL zX5S1rSSc~+OP_fLd}aNOaL?E;z`D#z#H96hE9O}@t-bgrExiDgPQlR5lNwR6C%}xK zAYx#;67u>IAgpIVCKFw&$D?s{*o<_2aPIK2>$cr+Yv=yx;9w8Z5pR3$?QzX@aB}0; zsB7*5`h85-Odlp+PO2!ydZs(wN68C?hlO!-Xp7;*W`JJCHhM#z94vA1_o!!aY9q9Mwd)+ z4-(c#Yfd@vpPviL&^$oCIE)K3ixdD} zLM>we?C->!XFO#Qyll}6;rPg``zs{|ea72f3O~N*XQWB7L2-P z-lT>tNLx)qA%06A7iQBXFbV%^tVRHdCXI&-Vx>ZXWF)Rwl=x8A56DhQoZ)jS#zgNT z(St(F*20W-e`L~QU|&;GC!YV* zVfeRyzdqKnnJFZGOt7>~;C^(^sc4f4*X0Wq~gIYv1 zNC7${1s&vV5#>t(?7MxOh<7euw@C1BzF%xxFo$&oZUE@!vSt~wF3*UWK1|1gDWC6W zG(F-`@QxR~4*qC(G3;wfqW$TA@drXD4N6wYWMRvqX|WYq=uq}g4k#0(E$P6WYewQ@ zSwU3g69bGOKO*z2D<9Ovg+m)eZ^qiL1keUx|Ka zbw_8)BmIhX#r)ySwg;~Pvx)+Y8s%17J}`hAtVkQo@h%{+!3I=N#k%Nw9uU$2MNc!3 z)7%F9?pMA8-tdC6U|-U6{`%$cweS50)vY=u*U1QGC57&ja?)>ajH0G)&qV^12+WGry4TIX1g8;E z*bDU_P+d;yOHA%%hC~Sg3nH@3pEDPZKkAA3bK%1uliM>8zo#cVcmEWw{q~pPj`(SH zFSjJWTSi^G@5yEvo4L@kjj@eC97d1cQ56Uo*Y$+K<7XfApP7qku-4426|e;$Fk+x-TjI zY?aaX*3*#!jrH^uSB@<}akwt0CgqEJ!ESbpDH=u95pzS2>SLBx z(7p_IcA?(5UD%3es2DFnTgY%OV%{*KX(2HdAx#M7mR)+?p7l4DB0|4&IgG%vugDBc zv6Yul;MC)O8_qoGx$uhL`GdViuawsR=)d79Z}?r@J`oyywHkmOL^FD6B3$>btM-~f zpZ)e1!^n5PS&Cl(l}eye2-U4hkWW=Jr3*Wj3S*|B5}|Y|RT`_d=_i)WDh6L}?kjVL z&yI4vl{UpKssHKw*EIV$Ept|{eEjVx%n3G)gWoY(Z2Srk3QG(Jhq`R`k{ggK2%=~z z><&;cGU>L|aqhei&8-G>77+w-^x_N$LIK_Xk_?e^7QYJK82|Q#A)9ICKmP-q|A~u= zYXy2!TX2wCsB#3=p%a7t*ZaS`S8ZB%^EcsHZ+(%WD^-}t<#m!<`a1(Ns_QfDO4V!H z{^-SNBqaZa0wFQvuOeL^TOgYmXGeQ{-i-8nr2EkyoYw5)ROVdzxS`7Vo3bdsdxvqz zQkq8G7PH2R=Tt!&oThkoxX(uv=B)qG>?B=?da31lHFR_h0;X*Xi_^hJJ{Fe$(VOAS zlTY8TOv!01>U|IUy~j(RS;Z+;7HVt5NMMv-bo|M%_V51%_9i{+t$zR`qu-!khUOqF zn19P&`4D8gZ~$X0GK%JvxzeC4R57Raf-{n<$R+HM)TkD)A3G3$IjTGV8`TPU_$*X6 zpk4s0I)!MT;!0{AH}Arjb0~>)7-zQ#$e-f|_&~M_ci6F&gn5dS3MfAlFPBA@rYA$l z=HaY?>n71;0EwA1)veFr=fLcFaN!^SC0zOT55drH9t$)356rrFD)-tOzX*42yt}C1 z3IZNDabYo%Qr-=B{usuh%jxtJp9Q_9!ACp>R)6V}LZgJrPQDx2MB{=Da*DK^mkSI5 zrfxlM^d*o#Q}(+obZ?h4=CR0Jbe%?`&ZhjS%6x+pHa z6DCP+C8#~E&QNEv+3fT_F`WIA{M2Yi*J z`|KsJfgfG*o&0$)()>6PLd~mM)OMaQ>95A%hpOh_m)Kv?U6RcZ*BlMaQ z>MlC|89Ipp$QA&c1VZ~Tg{FDj@_o>_+?oPDXx#I-@M-hWX!PG zFq)-Zj5f;mGBYY6az|)Txctzi4#wy5vpx_(lC|mby>zWI`OzCPJoIGiW9`ct4lNc4-O#>V7_p*1rF(T*|=rx_QI&e8JGIWP>12M2853g&FresN~w@h<8LL`*OtXnWli^^dB#uXJ`i3B)ZI_@q zBa#dh2>J&a`XFd$QrbR=~$mq>XE-04iNr+X~itHg6x-vui%*agZDgyyo z=F+bqsws{S$T%`@1RCRt0K*yS5Jb2I!>+qZ^TcZ)L9Y@ei=02) zF6$Yt zL3%#<2d}^M8W?&2wo#x_Ihu?kDS@Y*6`B z_U9vmZtkf)!%rsBZfKqAbasq_QzWgGsl>uhZOP*RLT^W9xSG+Obw)zXT9KJmg2Y3P zRX>s_Rqe@605K4O?2$nH;Ib+S1F-lp!`v@fTaT;aXC}R)N)XE-`anx_?BA-9>cJ_x zv6EFo$Y{c$Z!!)7`}dreS(r;sgTj7HNhoe|eLeukO2(e$a+2s<_^8Li_3ytH1|xDF z$kh4y2Ki&J%laSO0;9Lx0_**Ug<)enxmaZYqd^w=;l0C*(CLreW;HyoaT7hv<| zO>oIoAB5$vdK>hTlJ0r!4WBc9U=SCU_|-kOgGT}Xuc`jXJkc-dSY31F4gxTXE7(@_ zNuz_B`pA(`pe{#@o-)#peWO8Fuk!!9@Hp{4Uk^Aauuva&0?ndce~xp~ir&5;0=xEJ9Sp zckY0ScE|b(&m{`eq7KQS2w+xx0E!ikTu&t2;IGHE=nq83eAND7%*2o*kr6Mw@&AG627Rlrf`f%Pn2W=+rL*i1q?((``wliOkB zJ70(4r#=(*Gz~xHnWA6jW-unO1cH39A-1vh(u2GR?9e{o@zOlhLygJ?u;avvvP0S7 z1jLXzJqN#dmM*}E0}=AXsaMX zMsseE9Set^0S+$%5tAIKLZ7ts$1@g5;tGi%<%Ym4faO)7j%{M*WN`JuoF_c~xV+y! zh0^hGNPGeWK}i=#qA?(3#zabIgAJ^@p-O-g$!wGr$5OT2$0g8049>L8Lz|wdz0}PU zAgdFT`JBfA7=Gj+jNIADyb-StXHh-rb=8H$6uTfqpClzWi-0b=h*0TvgaUZai;IO_ z4eU;$fglT#jVX?>X2*Trrs{DuWBH%F9fqE89PDel3@t6-Aj7mr8cIu!~0gh}h2{?i8=j7`ld<)flyiuet8C&}&NSPA-ew zTH;CI@u_{Geq^cO4MH}IS&69k-i#BiaC!8fa{tA(7|1|mSCzM?;|1|-@E8Z;vE1zm zq*tr29!~9PQ=?AEvu0Nb0;gippT49Y`v%eimrWSac*${y8@4 zSpNd@(>Ya91VPzoaE%Ch*2AFI`hn{wT`-r2gS-Cw>!H_l!sCvE(fFK%78|jfw{o}? zI6WWEe%b3_(TUHD6aIJ@m^(kK5UbaG99CcZaS~@$X3*^7s#F4|f2yFiMn+K~r|nM= ziG+g07+LJ}rQBKE)8Hr3p82rrvS`oD*$QsZCrF(@b3cPSalVbWWLqHj*KVue%1HD( zP+e2Q$0)*G8qG?!mw{tl4Ba6f1|Pi;&f7=EyyS`xAX0AK3hd+4pkwk#2y?2A?Z}Yx zLB*0p9}xrRevqE!Arzu6_cdK-JyXQH4vZK@U}7ki-W5{9N)M>U$ zGEI`PMbe2adtq;<##u6=eMpf^ZZJQV{;4M>hq8!EI%i%90FdP=Ry}VjvQ!BANxnDNf%c^oblChoI&nOdSL_k`SL`kxB>NPa=oBFKk^&% z+I((H{2TrL&AoIN#kviI$>V`yckq!%!u22f{C(=}>0ZjSVD@3yp5SEJm4irF{Rphe z)LzvTS&qdx3=RrPWqD6l2)WugUcyPAkNOk@jCph#u{uLB!ofUwbLo%u3Yy7-S^Z_?P7&G4+3pAMI- zdY?G;bKAv19;6-8PFaEkT>1+^+RPh1B|1mi4YYP#m$3ps(!&R%j~fK-QuGh$WO-sr z-$8Ws&0Bgq3^*_0cD>Lw)EYf>-k-iBPV~p#Zy71i8(;o6qdinr)$WD_rQ zGT?CBMWUhD9TW)_EUm!ly)N#O)Faf!tB zvOEC%@qm_TOz^4Gru_9l(R0X^;I&xMh1e@(=NB6m%JD3Dh{ z!~kHC>si>?8BUDM1PsosKXcnb`p+vciB_`i%&6I=P_=-Pf&(xDqcIJb^XLK^>ybIx z*a;-*oS<-k;isLl$K{;0^1R@LlOFp5DKD5$fpDLbn)-2HCm;U}A z?X@^R_nmKn_5V%cQ1qNi43u1V@UqhrNFQVWAd7x{!4vf~Agfpy6rykK4P>n{6S5CA8w#Ku=9*G1=e=R?P;=!ae*>3)<_c^pNz+`u z*LGBa9OG=sCKTkuy~Hm`6NtJSK>d=%93>W)pH7$igjTs)cC-gs`b&4S7bJkLK(Qm7 z9pH)xdzJ#KDCqDH--o0bN3Y2Pout5F|ReQTP-%LKbWY5!DfzNQ;p;6^=$8 z&Wcu#G9cA8=pLLXQeO0O7<|k^=ryg1Ou6!+zabJa;c!U~StHxdgpf6ueG0J+#!;PG zT;E|DqPNy`>#?d4$g-7``SoyAB zn9G=`dFhjMqhNw&L6r2YGj&mk4U7`0#xCtFM1@uqX4KWN;2@SUhL?c}Cg$j#<>&kr z^qM+9xdYCB?*&Crc%{w>lz5Utth3Q2M>!V_I19>3;jlKZt`N>Ujh0WoO$0G<0Fn=rZWtFVviXL{Nuv>-!T`l1dd!F)% z%M;e6q%Ik$j+R+||lpyrR>bz|6qe=mHjB#!KK4F~se}ZDb>ft>QdzE{fGLSoz++*&`aP z{?z3#@{JMMF2e0d?d}yh(}c*Ay335Cbj|KIsEns0sJoCnGjg&t@dO(>;(FsPA`B*7 zwDPmNiD?}z_qNMo9YwNd0?CR(MMjc!7B|kl3}!U~g$W&V465q^ub+^EpCCUJ+htLt z_nJm-TnDQ^c{ya^;8h&~s+ILwZ&rpfl9JXZC#wM(N)$39j&^#4%5`Rq8AxOW-O8LL z38@Tqkcgc^RU$P;A_p9*2K+h~N8yJYJaNf~FUbbV*{^=XZfUUomi2J%d2a@@*g~Qe z4UNGg7eX(o^W!^^v7AxW3|+zNvE4#z(&14!A#JnqsHP)?P+BsXVm?SzS1xZdezr4@ z4OWO2A$O!Qh{@~3c?pX|I&X|}#+~3&aH%+4QKaztsh9P1uje_6`-VD1$T~2002ZJA zLg+QU@{O+|ban-T;fyEI5)}DaMPYv~JB=Or^uVYdL0m{)btuy(S2IEcSw{x`B=sN= ztvstR1kdSS*2BrK8^{O>tO`)siAHbwt2e8noiO8gm5e0a{)a0*-yzjBA)&h zRD}tM`io9F6?#cyo5w&MzitDov* zRTrq-r1U4t47ejV z4obdOZbPrgnUI))MhupKQpgjCu{$UaaUfK5<+{5y6bs}f1PzvtD(-`LD+b#zQ5*9g zeNz&hoeQG)P6~=(^DA%zgyQ&Y&J!DsyZ11c6UPes28%Z5XU_cH7eKG+k`G@>?iWyF z2P*PoYONStLj3@$o_QC7KFhix^A=n16{!a#y7Ik>qyXqTyo&uGu9d9LF!SVwk`d|Y z;8zlnY+Z9eNlKYN6B?zHG00)PIilLSuZ>V1rxTBu1H|8o0Xu__T?j)@8iHO@=Z=nq z3FZKhJpQC-lLw|EmlXihgA693E7L*TSgGr@hbp&_;$DT7$+Abi6`yYl=cbDYJveC` z>rlv4uJMw142~cMpy?xgD*Bp<=}hIea0MeHX#hc$)YzU0xS<0hH(ZZ(5W(a)Y#`(V zAU~UVwTXb527qLm5Rx+52XT#nPJlr9@-?*DLt;|U$SO~IIgrkX+83cA@L+M-iXefS zXV3Rc5kR>|*`V3-Jh zzwmiyLa%A$h7ojW)-*ZkO(|%``Jx|8Cs$MdDi!T{iMT=+Vb0&xeqz9}V@(ivE0F58 zF7Xn2|MO=>@r0mbk~?2H)h}tQ5KHFZbdK=q2sSt9eFJ53QXH!RG5-n2p8&n4)mN@Y z0#;5Ml-ZCL3|PlY_p4Az`?%eF3LOo-^jk$YujT|9Ag&|#p#p^oEeL0Rg@Xb~Qn2-3 zInd(*LB8{L7ci*9OM|FO2bnawUBc7}RIUe6@f>mJQAZvHi_hroJo=HZk6>GY`cR=w zp4;G%Pnc#*Oh*9ZsLj%$%1pvw#Cx_6DhkQ0^g3}W_dl;Q<`6n#=2xujYc=>+@lHxd z8`_vYhCzrz6>oSmoa`ZhPBw(})ZwAydK2l%njF1lJr0;va;To*v_FCR=a@*XO7$UP zm>`2neRK{bfH1x*>JMbl1fqp9JcwJ#*}Q+7&y@3mc2FEf6!CKt?K0E?cvD5Fzi=yf-KO~kvjk=iHB1ZAHjQTwwm(tPH~|Hfnv zl|5gcCTj?)TXJ5*$fXMB%HOFzI?fy-Gs_uEPMKIvS2owqd+T3AuW8Na)*v0JZ_-Yt z3Dd#QM*yc$zc4cE^-QyLpT@{eWMpEXzExxG$c$srVjN&d70bMV7F@A2NEI55Z4wyB zwZcrp#*b^1oh-^;(&){j1!9XhPARq}xmzy!+kb#F&o~nX=Ihx|$%tC@iOb>qzds+d z@}zNi9CdMOGZQ(e6FpG*d=P4Wo}#>&CYO(n$s@?m`mnhtPWupaA}|P48Z|HU zrS`Eq>#RRKt7knv(dQ#ds z%yezV+s=cd77oI)KYOKdg#>xco<#?4GZ?f@_bR?;CIlY_!8BqTROSF&K}ce{$_%g= zl2#I!x(hV5OltBmn$Dj$AI^Kr-l1kiWfqgw_R9pi1T(qqfw;l6FXuB7jwN2vZreY{ zeb4^XFqZWJ)_t6tc7`}t>OsNeMg6SOXVR?TnVdcidNSd;>;(%R2EC@+qaUtFj2yAd z-7%(3XT9_lu>7s(Nk65(^v7*yqy$hx7n8!EcBWsjMwPayU^u2@LI*=fF0Uwq`)0-sLP*8-^6UlNrwB3zmQ|z4+oX>2RC3ZY{(a57?Q}GTqZbIs3J5 zg!uytv~Br|G5{E+k$&5LnR~!YNZ-NGj*}xRmj{P`7l;bwBq;LOdJThEwIHR1QAJ7< zuh+EZ^Vi1JwoWN}ttf9?bJPwtj%DIa!yC3g^3noA;b3(0jM(XWTEwYZors)%bD)_o z;@B`+F=I*Gc_yT3c(9_2^(21A5?EXjc&*o^wUpAbow}?*27NdhKYN;zE_&wkURWT6 zOh#iQIYSdLdLJ~UiBngRHM;beo-oA_1B)omscHc_A#%B_X1J*;k!AEGg|5H$I@p_Z z$%if#9Af^k>&5St z%}0%_40D5RC@3m4-)Y0=AoZH8sNnijI^jvZ`AY|*e(Jbg)&g~<7@l^LaXvPfEK@Zm z4m6)@B<_pfoq;*c7zk>bJRg1ENA_y6k3{#;$c;DT@0BsOit%sMXVnSUxE2geB#O0ONQ!IxC<2mg)iH54Ea+GYp!GV{vQ5phfi+QO8{V z7rk5ODP8`_E3)G~$J)Bm*eg&PptLdQvobv5sEo>t2^iksR2i{ubyYN^@OKx&SXIO1 zXlA6oc4>`4L$5G}hbDncXFaI?>5cItI-40J>N#BU;eUiZO;>#KpNqPMQDb>qw)D1J zabXmJ!S;#KE|>@{BDPL69S~7VXz*Go4;bTy2g+$VY{?b@+eDYjShwrDEl~C$0J7zrm`j zt|%r7Ti9t*^t#VG8AZIdYxN~HPVyb02~{Y}$L}$BYKLbUxCYYX{W2lQUQMfuR>mSH z%>W$jUe(w98G6TSHMQlU3;rIKzhxg7^WA?F-Ey}Rcp!?S>Q#vZ;J%}}*2>pgl`gqf z^qnV+H3Tg!QqTcTa9zZJ#xsuJ23Wb#Tz2}W)oypJZYl4Wditv6b2L{Evf|g3?qTO zBOGW?5nW4>-AAAJM0mxE{|H`q`t#waBL`vr{P}r3eDziT94*RqRF>+8JiwlOw3~a> zIg@3ZR#Wof0l7&|%jM!_J8aMa0%rm_urKzx$_6+9qh*l2H2IfE&s-vI;j24HVfKCUN%ffziM9&E<#NcEyFZN;yXcBKC+E5T%PCPof2 z_S|E~k|>~E!jnE*=R|kcBDE%jjGUP{t9C2>ILoJ5)UBm5W+Y)S%jV9wlN^A)7ZHHB zN>~Jt29ohWd-!CqIq&6HeLVkxAr9(!mCUx3)fy5N;JwlB^>*-8N@>eOngH4^JC*I) zv3_^dcD@d#pUkA(wt{tuW=`sHs+I%}5LQ*noGSvSoO}wbc=KPuqLcUhSef0W^WXhW z*zl7dOFhb#PM1_gmvq#mrtzqg)Ouo+CkTNZQv>9;x)hCPAnVnvySG)WPkKL^p7Okp z*MaCERyU9s$VzuPYJh}xzOXn&rvTAC>~Vq4Ffl-B&5MXII1`u41WWVlTzX)sp^-KGQ zG5_Ij;nI)%qmC=^cqtNkanTv0Pm)fwl&Xe`#(m(PR7P)Rx~g==`=YawP8#ay-UNAJ zLFCkwAoxjW2(0FoR3fCGyF?L)A^F&2UqF#7~MZN_W z$8oG(Ch+3%@acG*&j0132hF`@-pJ+(3m#pHN2{&Uzq)c~v%lP#716HUrMT zh4DySRE<>^PIatIA2Pt2LnDCHl$c|AG4L4tj4eT5`2%x3!}A`W!0f~6x} z4-NXIgCvHZ@k}`Ww~miUc}h?1-_x}ImYd;K=e|xauy{5=Q7SZ0OhQqks;ZbwVEPh_ zhzFw{Fr7etJ0ooX%V?v48yROEw?SU@;#%!>aXJsBpyRxY3)EUKW< z{UV-0L(2dP$F&R;Gz|nO9OAr0${mt*S~`>@D^$t+#_SfVR1$R3J3*N41!@=k$k9Apfb~HkMMRx8Hv_!4lIthuFgiy z&dCD0Wk#6}H7oG6v?*w4^s=h)3~_K(@Z`{eEw{XR=<8 z{_Q8h=B-;wHH9o$MwmjcGVO#0k~$+f67zlaGoO#1fdkKk)1}Kl^>KL9d2h{qgHXmk zG}WwPJ)BP=;k@aesscUB-z;wel)>54l+SdOI8ki`e#~lPB{SMjp8VcDY7nJV*D#U` zNa$qt&MY;|agJ|=W_s$=DN&8fvJOOEk#;gJt4V_y1^55ONsQ9i~iXVuj06~d6b z#!Y>0(ei3ac^pUru(K*6wfHOr=Sea)9X2fd~@zWPry z>NX8CAR^ijusrFi(lE4b<@UXI#k(Fb#{BaYZ_9r|V*>=v&{(7$^%dP*%D8spS`o%) z#?cw+#*-8P3Y&yi$?UVDO0>{hQMLL;Y#)jUhxpLC{Mp}@0-T*G&K1lcGsOHkJ5Uxa zsVv?@R2WtkO|jJlLx%HqzpFjz4LW~{Qbvk%kwGRUwrgXZ|S(!-E1H6{Q@ z8}az2&xFR-aW4SZ--pL4Rj|%Br(n*$I$VPgTS9YTq&PPP9Ww#=*gL1z8Yk8{nt=l? zaxn-KRMk|Kp;BLKHO&NIB1+^>ytZZgHn{xLpMYLd673o3oGSdb&xx4a=H@+nEe-C_ zvHSi~uU=DP$frK%nXvx*w_qPA!pb$Il^st;HftP-8V4Z0*d~*qe(rMxV8_(Z-5eu_?cu z#tA}0H1YHNJ3X`RTi=L2&#yzTY0=Z43GZEgVc9lk)Chy}(hg%NGM%<0>-Yetq`JIm z#oOV;=Pk;8wP={qbdrXD=ZS12lHG*HwD!hWayB1*a^zAS8ryrUVa)skDj{<{%=CR6 z*XmX``{$b6ef)0-l)ohvJBG^D)djTgo>%AjBXQ-1oI&T-1? zuA$tr%Fj;Os!QqAlb!+BeX93&NT&3vKmRjW{pqyE@5(w%RJxL7YF`KD&xap=V|_;B z{hg9tdHJWWgiAmA!R#VJnxuAAq|5VLQ8evtoGgage#j&&`i6PSd?3$H8Sngr#ptq* zR&u=0>dQ2Z_<7X$t#@|p^DStmjXUn_d&Jz~_-!zs#1>Q+$%Bz95YdLh!ZldD^qT>7 z2;{J)ay^!OnGME7BShVamVmj8s?xIU;;H!Ia1e(h5-!{eNB61mxi7D~4(__=F4I5T zZ~CM*8|^$ddgu5!%$hkXBk}%92_66V&;JzOeEwhN@8i41jlYoV2WbP-UunqW5#&_7 zswxA9`IUeUg073xk~C$&8l34bz_h+^Y>76&RopHrDuHHSJ}YR|uJ7Nqy4l~U$Qe`L z6AwQpgzBPvZsqu-yrTz?sH2r_8Wav!1JhPy7d|*Z;{XF6HtK*}E{w!VP5yn;+nhAr41Kk@R@4vOnk7fy=3C%pIy`J|McTLI55yZyYKe++(6hiL5$SID6HD{$tiyo zt_IOn33}Bn!(swd9MOM#FVMSsT9rl(F#~cJkj?EtD3#NS*L#-#ZMMYsCM5=W)tmk_ zGl(!EF$13lipYuW@OizzC5V$A`s;Jv3g^A)?e|+(dMn8K@7c}= z0OfH`w!!Sa^i*f!`*$BT9X)mnLhUCWak2Aoc9`r!I{jD(B9wBfQv_r%JM*ORL1Qp$ zvj)zG^9v&Lvy~A^GCLI!(J;45XegrCr#9Vgzkzx4;5%RX#y+iMpa0$q;ew0)4oWS` z6~DR70SvK7{4JO_kb_L-&7B8~bScjrKi&|TazhT3PRJ-clOtw8&qcLp2$-o5wKiBK zXxsxxc}^y_B~A$hMjbP(!Kaur>LrFmi3p9OW+1D9p3Z6I=y%5jSd^U^Cvem`+g0n_ zDAF&Uj^?{(&Z7?>c5PUj3imrEq)*BaO#h&>F_-8HGc)irA*Cxv7i^egIZAOcG;2dn9D96@ zVA{NOGet4!J*$ISiQ#4MegLbtI%`-tk_LiyrZ3J;d?*kRbwQBd=|^S9#iFEKw=|RH zfE-B#=us)2A$m(21cgC#P;(m#<TCMjir<2$<0nL&k|1C$UfO!cljVq6!bXMh}6C z5Zb82Gd{KJ`p9YGMK)2<0i<*t5O`lqi8(13$emIPG@tr)jBLE8DfSp!+vy_+Eb?gG`FJ(@XUkBII zWDph3h!%^J&F68VXbmXIFv|QpU%qj_#Ze}g&g!eKfXhE~C0=l*9r?E_^)fL+4DV!~ zF`^QT=wwbbXcuVWGDGm>oX)SD?%^@@W|`6EW$-q@pyhG~AciN8fGgM5>`$X|beQHF@hVmcf0b`(@6Ap5W`XSve=S60<`y7#DW;J(%crgCZ74nQ-7# zH`S5Tj#rc~08Ba8$LhUYzYxvnT??ao>AHxZ`!mot88tVgoubikpH3y2&gUo2{6PD| zjr6ho%2L34r4P}oSyr(!7fL^)UEuXW4NeAyOg}KRF?42CZOWOUAr$T5x=X(%m^PS1 z{q?ijdD*iCeN&w7&2+!anNr^q7Oag-IULeEizbbw9D+c3MTF*;H}mXcOHEx2X}K%9 zTa2|Qd!-2#tjnRI4RK6J713pIbNO5_x)qomf?h;gib%iiGhaMlk?3i=NZQZT`6;@G zHbrYZZ$XZ2N$#P1bWXt(Y}vXgOvgaL#)bkz5MUDq7nO$4&`waBtoQ~VD;&8xcnba~ zcU6W^ig2cHP0N*bJB=MD)B@w!^jG8aS?;`Q z#BW`|&hblhRMeNtkggz{G8aB42ZY(Y)Iw|Birxfn%6IG1;MFLLCbD2a1DAq<45(8U^RPo2UQkLj1)q`k)z2R*w91|Ii$>)Wk!du_e)1`U-#)R?A6hM`y-_z z#Gm#1r(^u7p($`Xphs`&zFf!F-&BcoJ?RMw*dWN4^<qV)ow*#*4Moc zjWM&nO==3AX%Og*toRJb)*R4M!5&zc>7H~=dfiTcU$qkUFtzqHBW#@LxCQ-3#Q`14 z@p_ZQJkm0C0a=0oG-osDTZ$`v&gk7l4E$R0yLXJOqXUTd8F6JDo<)xgaEUac*HNKK z2mHuCe>B&-Klh36X*%ZUCuEmYS}l@QrhwwsHD5wTbX7e+Oj~B5V_K`5%Q9$;G-Z!U zWv)A!?EJV&JX1~v3?Vg(KzS&{)u2}1Bji_3{=Y5Pz#gVOXUpCnx_;av6t8#uZQGVW52+{Sgt75)_prWQ@!RRi zIUBlB*@c#weMaA-9`Q_9{Y5B&iZbb-$bfhw@D?jW9}#2D9&v)@u%a$pNlX`@;hZ+3 z$VVKq&YKZEKDYjR>*3ljeKyBq4jtS3YY+D}rSP}QKXnBf5&4b6II+=CtVMiFw^?jE z?lOyA16-!x1rwWREP|>n5Gb!JCO=OY9xH1U731!dnOn2knNj2a=lYEkqtHXzW1W+e zzis=)E``)5-KH-gQ(fpU&bg8RL322uoxzD5|D9Zr^Y_EPLat;g#pUW{=Jd-5*KaUK-quZATZAr#)JN zUS9R|+-{nnDu#m^I}8Aft|YB0T3*fH0gq;6I$|9)Sa%gBa;g`S$cS_+{`-*Hv5x3E z+PVsQOg%GaN_DrrEH-AW>aXb79aRW3!|`S}+p#H3!VJyC_U3>vVje4Y9r{r9y_^9W zut+OHVahcCLr9)HS!r_w5Y7xd2!pTsy5gU%8Am&fJF-+bB=VA=ID2vGP*dBXBI1e8v|TRY@=%+3*Fs9yMK+5=EH+IeiHZhZYN zGA1MrTRFZ5KF#TpgMCP?-<-8J_GUOt*691pJpj>T^yratIgOc-S3CG};V3rwn}n7y zGE<*0dyaMq@(y;^kF2p3B%&Kc*R143VUI-su=+K(7dr-pqD}$C&gkfcj-QuI&B;VGG*~Cdd8?@V zqHQ4u$}+trFd3x1FdIV{+b*u3|IdzPu$O6{m=lf}7_26C-V}=&fL*cSlU@xJ(=n+m z6C)V@48pjU!G!1{XN46-H?w1qKZF@I7mI%JA#JxOKb(dzto)IU%8E|Kbp;tJC+e zvleALNbePm^7ml;p{nEon|RE?EY5W>jP8_G2Xj<(W@6?E@$sLuDbvkrSOqtY$RadQ&34}UzW%Oe+-%Khe zEuIk_viRrT5rdhyODxXxOeSbBv;2=g8!ad!u`{Ag4VN9-P z-_v0G;z-o9a}Rd8yt?C9tOz-HC{;wF$Z6p{7C+K33J=doHAfa<>~_M_&=~nl+42PA zr~}>r=0kJAfMz||fN8Fe+%vMJ!~;0vH?)&ShE1m?Kbz_vn71Iis8Y}e27Uk(T%X5S zRT6zU0sx3E$rFRAqqJpfyBUsXmnnnLUeUOzqcU=HUvs&D$AYUkcQPvZ-!wn_vFvuc zTG_pAd*s4vFJCLXHjp}Zm7z^IxjER0w`-aX?rkjVbpVG zYAMr_bq=(=(P+1-QD*UjsTIuLst{Cd;QkOCI06j-MPb9$pj&RzbI81z;|a~X+0U_B5-pJ9tpbxHdka$dU!5MrCb z5Eqbb2pJD75*caP+))-MQ3O0VL76U<;R8io8TXWBASml~(gS5&W(Lk!F)BLtaG-kP z01DkuNC6CXduC4&|%EIqM1k0b?QT#+9)w*yg|pRJaavh z+8|2cLCj*tm50+>NE5N9mmRIi7pkiOdT4MD82yxjg7XY;G9h#_VO<^!8dLsYbUUEF z6pS4d3zZfPeUG{lOnZf;1u029i^^c>Z0Vzu*`jfQ#TG@=zkRW9eYxyTnALaMzUrAa z?VX=o$Q!qKtwUg5~_qG9fWn zr2`A(12qDU+F>_wF`^MOG0gK0PP;Zuemq`9N&i>Em^c|7p;2xFi|qiS>NuGMtSORS zZqyq{t22%mi?2CFEg`JgTEX;D`gxTs)ZW60F4Ll%1TqR#o}=qdvtpfjjI$;>w!axO zrTr$>C5`z`^=qv=V)GXR+B#}UJ05jnfKs4i7LvzK@>-D(A_fsyZMm!=x#G=X_Ej=! zaFOVCPU_>qF7;D$&OLh)Kn1vE5@+{G@k8`RHs0fnh#3&Y0Bo3KNr5~WoG`geEMp)^ zJ1a-)2Gl0n1zU<}G-_O-ZYX`RE@OOw@vpim4aEEH$iRJMBtrdkxUG9D?B}%qnKP!a z$&b2$I3!gy>~esq641W(MNdJijP>WTu4jFZ{G4SRJT_0?8=1eG$b7lSWy)Ccgm5~@ z`T3S!7bF9*(x43}kO6#U;VhJ;vy2>E5mQy)hDy+<_aw^J?uT#{;f?{&nwg9@i zM9#}S&^*Dk(dk69E~-lU?UZKR*0gM${FyGl&}x~#z57Kt0O^1*XG&qrkMu3*OT#&w zP7})j<8kQud`87OW+*4?>r8z6CS4|O^Swq6%CIlN1;bdF%i+IH8By#IDlN03 zD1WZED2IM?d#o(rzOp2!#QEbSBFheXlQujdd7`k-(Y^q;a<;!Xv0cy!F7z;MV@9kD z^GaXS0l9+oP9&HzW(!*V*!Ol-Et~kC@l|ku(g9=6lEW(tEAc5s*xnPGU(Ju*v6+eg0{t#Vr{L|lvF z&P=RaE;Ez4rxVIi8PpfU3`!TI0ZtliPqR;zoutkcE;DA-K>ajSCsb9m=Na8{=3GvP zh*`~OFQ2mOmkILlbKf}jnODkPfmSGv+O+{5{Hz<1Nx!8`^XNYPQ8FTw8 zIKb(EGiOSZkwN<&)*l(PJzUYt-knzhGV16cSltasC&cR&p$;PepBsuOX5RNsXO>IZ zq7RzM5LEQD_+_!lB9=TelwXlq^g?j5yjOuTwxT_d7)Az{N!JjU(l(lWJb_rGW4m=t zh(iZ~CvMvI(lSbgneZ)M60J&Uqx7f>Jy~gwdT%Q(N%r-(b3fazy==#Ay~k$mO>%G$ z)10FR&WZiIC=sMPS%Xje;Mp(dtOL1xPDK>CJOUWwN8LoaJj^fG(%)K6SuWNd%#ki- znOvXja0K2u;l${W?I~PdQD5obEOs^)z2sj`f3r~&cpYC+Gk?-~sZw@9Gb~Hn1_gcX zk|nn>C52pptzP?Uek$hMlIeA_fx5oD7xZN#r(E4~WxqhD zWjno94PThnan7GMwwH04dz%hAbEY(K%p(UOOs`yRAXe|Fg%aPpaTwK1URno&C@{$?NA~K0XPtWo*!oV=yes8)?c{BzR8Lc=S zn%e-%WTK-^M@**-CrAs1SwpZHi?fo7Q=>>D6fiYfdw!vaH6ux0HUt6 zXSShDFG-8iX>~n5=bW*|D_9Ozdcc^I)5DK>_=-rJ<(Y_Gk4zdGK@~J&-W=N}agRLs zP6-W0#BmBnL>Kugb2=~$c)G{Od%9%3JoH?ooIRf}qF}T66 z)K~4ZTwdWOlE{B8yPD=SIcJ&|f%`OK$R=s@A1V~}(HCPBt2AD35(NnfCuHL)z?IH{{_S164Y!%%^^%{BGJY5F2F z)9N#r(@@cg>R=}ayVO{KTg$y~7NR90b6DNKwf~&X10=?q>5ySgPLDqJ(dPtLEswMs z@ZB2fEb1rA1NTj{)qh9mXhc8ji;j$_j4UZizf~#ng4Gv2M)~mD*$h*rw$9AO$2g#EDDvyk8TVT=`v@BmZXI6Mi0RSSyu zIDv9t2vdn@P}yRALdo`wK4`Z&ki=aR^DOI(9vWzA(;>;6oF0ALkxS$1#`5@eFd@=p z6!VEmgRyE^jPf3DJ*tliaf&>44E6aS`W-YZY!&ZD;gz~TCIjoG>b~LZjeMb52fLLF zQbNhV`Z`4@t$u(~qU+2Qy_c9zCSc#perWf#d3$dsUP??k0*7ciq?t3N!J)yyXzQ$q z%aLbgq>F=9M{z$HMZJ3Oot(QaR;Y7@8>Yc#B{SxKXh3D_azF>PeSv^&nA5qy+089w zVy@4)Z^SSIm|}Tnbfn3|{js1f{#sU19Q#xoeRSM~xXKeoR?v;!YAy(-_r;=?+boQ4W2J&h&PFAO1#0U)2wND{mdW zwF3`=^q^r*PDc+NJsexTJPyV%FL8D!Co`JXT|KrtCEji<{6}IoyB0Ack3AU9sFrlnUAwYQ>dl!;s)5-xa3h8r;?SNYFPY80 z-@->}5(i>HLw7BdNC2aD0PfZ`UI;E*>koyVDKi0_X*-3_wGbs!^ia-L=jrjFWJ*pC zTIS?*%u|m^NV+t-XU-CV8pm(f_^ybkUCboz7d3v06GJQ+3L^%0Fa~o#nZ6yu8OAZH zLC~em;vCUX9t0a2PI&vBUxX@wDq$Lf4lB;g*w@#W<1I;z8H?C@O{iL{Z~M-zBk&+j z4|?Y0bj(S|3_>)VqF%Zv_G*yVYLdwBMv>o5FIs4bZ%)A+b|sAF8lC`vQg1AhE0EM$ z?f7ApR75*{{Y)tQkyE`2(D=}WXCCpg0szPs#nMY!IYx)lBTQxc|`j9}$>C!`*Ic-Xe z8pZx_M4U4tlMY8D8(?vZR(}_K*XLKrB)zg5KfzBY!Z9hjmPw+@&HbioP^by<|16@D zE}FJMv{K}Gi(WN|`-D;ydTir0aSp9(&4Ftk$_#1KFAZ}xQ)JfRIQB!4LCys4hJp(N z3Wmfyk&`o%0-ZZgK2_b;q1zk4SpQr`-p3UGYq#1^Y)Yep4=UMZYLF8gF=J~aUR@Wz zkBt7u=mQ=h^gyIvTIOt~6Hh#GXwpp%w%UEebv=1P-d??gYU$8Mx&SZj`(wQCd?f9*0;XZfnTLxG3WiI#I&Im1|nt- zMs6DHYxgbecDn;E9aa{TYsF6w1FWxdWKv2Yo$iZzWGC$@uYG+-ET"Matrix is an open network for secure, decentralised communication." "Welcome back!" "Sign in to %1$s" + "Open Element Classic" + "Open Element Classic on your device" + "Go to Settings > Security & Privacy" + "In Cryptography keys management, select Encrypted message recovery" + "Follow the instructions to enable your key storage" + "Come back to %1$s" + "Enable your key storage before proceeding to %1$s" "Version %1$s" "Sign in manually" "Sign in to %1$s" "Sign in with QR code" "Create account" + "Welcome back" "Welcome to the fastest %1$s ever. Supercharged for speed and simplicity." "Welcome to %1$s. Supercharged, for speed and simplicity." "Be in your element" 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 953693b40d..1d24d775be 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 @@ -15,6 +15,7 @@ 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.classic.FakeElementClassicConnection import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -39,6 +40,7 @@ class DefaultLoginEntryPointTest { accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), oidcActionFlow = FakeOidcActionFlow(), appCoroutineScope = backgroundScope, + elementClassicConnection = FakeElementClassicConnection(), ) } val callback = object : LoginEntryPoint.Callback { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt new file mode 100644 index 0000000000..1c76a93650 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -0,0 +1,429 @@ +/* + * 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.features.login.impl.classic + +import android.graphics.Bitmap +import android.os.Bundle +import androidx.core.graphics.createBitmap +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.libraries.matrix.api.auth.ElementClassicSession +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_SECRET +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.A_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultElementClassicConnectionTest { + @Test + fun `connection can be started Element Classic service can be bound`() = runTest { + val connection = createDefaultElementClassicConnection( + serviceBinder = FakeServiceBinder( + bindServiceResult = { + // Element Classic is found + true + }, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + connection.start() + runCurrent() + expectNoEvents() + } + } + + @Test + fun `connection can be started Element Classic service cannot be bound`() = runTest { + val setElementClassicSessionResult = lambdaRecorder { } + val connection = createDefaultElementClassicConnection( + serviceBinder = FakeServiceBinder( + bindServiceResult = { + // Element Classic not found + false + }, + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = setElementClassicSessionResult, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + connection.start() + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicNotFound) + setElementClassicSessionResult.assertions().isCalledOnce().with(value(null)) + } + } + + @Test + fun `connection cannot be started in case of security error`() = runTest { + val setElementClassicSessionResult = lambdaRecorder { } + val connection = createDefaultElementClassicConnection( + serviceBinder = FakeServiceBinder( + bindServiceResult = { throw SecurityException(A_FAILURE_REASON) }, + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = setElementClassicSessionResult, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + connection.start() + assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java) + setElementClassicSessionResult.assertions().isCalledOnce().with(value(null)) + } + } + + @Test + fun `requestSession when messenger is not ready has no effect`() = runTest { + val connection = createDefaultElementClassicConnection() + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + connection.requestSession() + runCurrent() + expectNoEvents() + } + } + + @Test + fun `when an error is received, an error is emitted`() = runTest { + val connection = createDefaultElementClassicConnection( + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_ERROR_STR, A_FAILURE_REASON) + } + ) + assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java) + } + } + + @Test + fun `when there is no Element Classic session, ElementClassicReadyNoSession is emitted`() = runTest { + val connection = createDefaultElementClassicConnection( + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving no session from Element Classic + connection.onSessionReceived(Bundle()) + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicReadyNoSession) + } + } + + @Test + fun `when there is Element Classic session with empty userId, ElementClassicReadyNoSession is emitted`() = runTest { + val connection = createDefaultElementClassicConnection( + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving empty userId from Element Classic + connection.onSessionReceived(Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, "") + }) + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicReadyNoSession) + } + } + + @Test + fun `when session is received, but homeserver is not supported, an error is emitted`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(false) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + } + ) + assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java) + } + } + + @Test + fun `when session is received without secrets, and homeserver is supported, ElementClassicReady is emitted`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = null, + secrets = null, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = null, + avatar = null, + ) + ) + } + } + + @Test + fun `when session is received with all data including key backup, and homeserver is supported, ElementClassicReady is emitted`() { + `when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`( + withKeyBackup = true, + ) + } + + @Test + fun `when session is received with all data without key backup, and homeserver is supported, ElementClassicReady is emitted - backup key is missing`() { + `when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`( + withKeyBackup = false, + ) + } + + private fun `when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`( + withKeyBackup: Boolean, + ) = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + doSecretsContainBackupKeyResult = { _, _, _ -> withKeyBackup }, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, A_HOMESERVER_URL) + putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET) + putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, ROOM_KEYS_VERSION) + putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, A_USER_NAME) + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = A_HOMESERVER_URL, + secrets = A_SECRET, + roomKeysVersion = ROOM_KEYS_VERSION, + doesContainBackupKey = withKeyBackup, + ), + displayName = A_USER_NAME, + avatar = null, + ) + ) + } + } + + @Test + fun `when session is received with empty data, and homeserver is supported, ElementClassicReady is emitted`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, "") + putString(DefaultElementClassicConnection.KEY_SECRETS_STR, "") + putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, "") + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = null, + secrets = null, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = null, + avatar = null, + ) + ) + } + } + + @Test + fun `when avatar is received when the state is not ElementClassicReady, nothing happen`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving an avatar from Element Classic + connection.onAvatarReceived(Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + }) + runCurrent() + expectNoEvents() + } + } + + @Test + fun `when avatar is received when the state is ElementClassicReady with a different user, nothing happen`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = null, + secrets = null, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = null, + avatar = null, + ) + ) + // Simulate receiving an avatar for another user from Element Classic + connection.onAvatarReceived(Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID_2.value) + putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + }) + runCurrent() + expectNoEvents() + } + } + + @Test + fun `when avatar is received, the state is updated`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = null, + secrets = null, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = null, + avatar = null, + ) + ) + // Simulate receiving an avatar from Element Classic + connection.onAvatarReceived(Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + }) + assertThat((awaitItem() as? ElementClassicConnectionState.ElementClassicReady)?.avatar).isNotNull() + } + } + + private fun TestScope.createDefaultElementClassicConnection( + serviceBinder: ServiceBinder = FakeServiceBinder( + bindServiceResult = { true }, + unbindServiceResult = { }, + ), + coroutineScope: CoroutineScope = backgroundScope, + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + ) = DefaultElementClassicConnection( + serviceBinder = serviceBinder, + coroutineScope = coroutineScope, + matrixAuthenticationService = matrixAuthenticationService, + homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker, + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt similarity index 72% rename from features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt rename to features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt index 2c41d2ed0f..6b601543ce 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt @@ -5,8 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.login.impl.screens.onboarding.classic +package io.element.android.features.login.impl.classic +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.tests.testutils.lambda.lambdaError import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,12 +16,14 @@ import kotlinx.coroutines.flow.asStateFlow class FakeElementClassicConnection( private val startResult: () -> Unit = { lambdaError() }, private val stopResult: () -> Unit = { lambdaError() }, - private val requestDataResult: () -> Unit = { lambdaError() }, + private val requestSessionResult: () -> Unit = { lambdaError() }, + private val requestAvatarResult: (UserId) -> Unit = { lambdaError() }, initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle ) : ElementClassicConnection { override fun start() = startResult() override fun stop() = stopResult() - override fun requestData() = requestDataResult() + override fun requestSession() = requestSessionResult() + override fun requestAvatar(userId: UserId) = requestAvatarResult(userId) private val mutableStateFlow = MutableStateFlow(initialState) override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() suspend fun emitState(state: ElementClassicConnectionState) { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt new file mode 100644 index 0000000000..0a24f13fa7 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.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.login.impl.classic + +import android.content.Intent +import android.content.ServiceConnection +import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeServiceBinder( + private val bindServiceResult: () -> Boolean = { lambdaError() }, + private val unbindServiceResult: () -> Unit = { lambdaError() }, +) : ServiceBinder { + override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean { + return bindServiceResult() + } + + override fun unbindService(conn: ServiceConnection) { + unbindServiceResult() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt new file mode 100644 index 0000000000..9039743fd5 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.classic + +import android.graphics.Bitmap +import io.element.android.libraries.matrix.api.auth.ElementClassicSession +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.A_USER_ID + +internal const val ROOM_KEYS_VERSION = "roomKeysVersion as Json data" + +fun anElementClassicReady( + elementClassicSession: ElementClassicSession = anElementClassicSession(), + displayName: String? = null, + avatar: Bitmap? = null, +) = ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = elementClassicSession, + displayName = displayName, + avatar = avatar, +) + +fun anElementClassicSession( + userId: UserId = A_USER_ID, + homeserverUrl: String? = null, + secrets: String? = null, + roomKeysVersion: String? = null, + doesContainBackupKey: Boolean = false, +) = ElementClassicSession( + userId = userId, + homeserverUrl = homeserverUrl, + secrets = secrets, + roomKeysVersion = roomKeysVersion, + doesContainBackupKey = doesContainBackupKey, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt new file mode 100644 index 0000000000..1184bb91af --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt @@ -0,0 +1,278 @@ +/* + * 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.features.login.impl.screens.classic + +import androidx.core.graphics.createBitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +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 +import io.element.android.features.login.impl.classic.anElementClassicReady +import io.element.android.features.login.impl.classic.anElementClassicSession +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_SECRET +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.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +// Use AndroidJUnit4 for the test with the Bitmap. +@RunWith(AndroidJUnit4::class) +class ClassicFlowNodeHelperTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `initial state`() = runTest { + createHelper() + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + } + } + + @Test + fun `after a few seconds in Idle, NavigateToOnBoarding is emitted`() = runTest { + createHelper() + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if a session with the same account already exists`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ) + ) + ), + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if Element Classic is not found`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicNotFound + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if Element Classic has no session`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReadyNoSession + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if there has been an error`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + ElementClassicConnectionState.Error(A_FAILURE_REASON) + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to login with classic when the session can be retrieved`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + } + } + + @Test + fun `navigate to login with classic when the session can be retrieved - ignore avatar update`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + // When the avatar is retrieved, no new event is emitted + elementClassicConnection.emitState( + anElementClassicReady( + avatar = createBitmap(1, 1) + ) + ) + expectNoEvents() + } + } + + @Test + fun `navigate to login with classic when the session can be retrieved and navigate again once the session is verified`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + secrets = A_SECRET, + ) + ) + ) + val readyState = awaitItem() + assertThat(readyState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + // When the secret with the key backup is retrieved, NavigateToLoginWithClassic is emitted again + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + secrets = A_SECRET + A_SECRET, + ) + ) + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + } + } + + @Test + fun `navigate to login with classic if a session with another account already exists`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID_2.value, + ) + ) + ), + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + } + } + + @Test + fun `navigate to login with classic but do not navigate to OnBoarding once the user is logged in`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + val sessionStore = InMemorySessionStore( + initialList = listOf() + ) + createHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val navigateToLoginWithClassicState = awaitItem() + assertThat(navigateToLoginWithClassicState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + // User actually logs in + sessionStore.addSession( + aSessionData( + sessionId = A_USER_ID.value, + ) + ) + expectNoEvents() + } + } +} + +private fun createHelper( + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), + sessionStore: SessionStore = InMemorySessionStore(), +) = ClassicFlowNodeHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt new file mode 100644 index 0000000000..e5ff91aa91 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.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.login.impl.screens.classic.loginwithclassic + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLoginWithClassicNavigator( + private val navigateToMissingKeyBackupResult: () -> Unit = { lambdaError() }, +) : LoginWithClassicNavigator { + override fun navigateToMissingKeyBackup() { + navigateToMissingKeyBackupResult() + } +} 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 new file mode 100644 index 0000000000..3bd2dd0cf8 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt @@ -0,0 +1,293 @@ +/* + * 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.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.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.features.login.impl.classic.FakeElementClassicConnection +import io.element.android.features.login.impl.classic.ROOM_KEYS_VERSION +import io.element.android.features.login.impl.classic.anElementClassicReady +import io.element.android.features.login.impl.classic.anElementClassicSession +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.createLoginHelper +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoginWithClassicPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.isElementPro).isFalse() + assertThat(initialState.userId).isEqualTo(A_USER_ID) + assertThat(initialState.displayName).isNull() + assertThat(initialState.avatar).isNull() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + assertThat(initialState.loginMode.isUninitialized()).isTrue() + } + } + + @Test + fun `present - initial state - element Pro`() = runTest { + val presenter = createPresenter( + isEnterpriseBuild = true, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.isElementPro).isTrue() + } + } + + @Test + fun `present - refresh data invokes the expected methods`() = runTest { + val requestAvatarResult = lambdaRecorder { } + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + requestAvatarResult = requestAvatarResult, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = A_SECRET, + roomKeysVersion = ROOM_KEYS_VERSION, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.RefreshData) + requestAvatarResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - start login with correct state - user can login`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = A_SECRET, + roomKeysVersion = ROOM_KEYS_VERSION, + doesContainBackupKey = true, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.Submit) + val loadingState = awaitItem() + assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() + skipItems(1) + } + } + + @Test + fun `present - start login with no secrets - user can login and will have to verify manually`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = null, + roomKeysVersion = null, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.Submit) + val loadingState = awaitItem() + assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() + skipItems(1) + } + } + + @Test + fun `present - start login with secrets and without key backup - user will see the screen to enable key backup`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val navigateToMissingKeyBackupResult = lambdaRecorder { } + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + navigator = FakeLoginWithClassicNavigator( + navigateToMissingKeyBackupResult = navigateToMissingKeyBackupResult, + ), + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = A_SECRET, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.Submit) + navigateToMissingKeyBackupResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - start login with secrets and with invalid key backup - user will see the screen to enable key backup`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val navigateToMissingKeyBackupResult = lambdaRecorder { } + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + navigator = FakeLoginWithClassicNavigator( + navigateToMissingKeyBackupResult = navigateToMissingKeyBackupResult, + ), + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = A_SECRET, + roomKeysVersion = ROOM_KEYS_VERSION, + // false here + doesContainBackupKey = false, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.Submit) + navigateToMissingKeyBackupResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - submit in wrong state and clear error`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + ElementClassicConnectionState.Error( + error = A_FAILURE_REASON, + ) + ) + val initialState = awaitItem() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + initialState.eventSink(LoginWithClassicEvent.Submit) + val errorState = awaitItem() + assertThat(errorState.loginWithClassicAction.isFailure()).isTrue() + errorState.eventSink(LoginWithClassicEvent.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginWithClassicAction.isUninitialized()).isTrue() + } + } +} + +private fun createPresenter( + userId: UserId = A_USER_ID, + navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(), + loginHelper: LoginHelper = createLoginHelper(), + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), + accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + isEnterpriseBuild: Boolean = false, +) = LoginWithClassicPresenter( + userId = userId, + navigator = navigator, + loginHelper = loginHelper, + elementClassicConnection = elementClassicConnection, + accountProviderDataSource = accountProviderDataSource, + buildMeta = aBuildMeta( + isEnterpriseBuild = isEnterpriseBuild, + ), +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt new file mode 100644 index 0000000000..2a4af8bf5a --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt @@ -0,0 +1,55 @@ +/* + * 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.impl.screens.classic.missingkeybackup + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.FakeElementClassicConnection +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MissingKeyBackupPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) + } + } + + @Test + fun `present - when the screen is resumed twice, the start over method is called`() = runTest { + val requestSessionResult = lambdaRecorder { } + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + requestSessionResult = requestSessionResult, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MissingKeyBackupEvent.OnResume) + expectNoEvents() + initialState.eventSink(MissingKeyBackupEvent.OnResume) + requestSessionResult.assertions().isCalledOnce() + } + } +} + +private fun createPresenter( + buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), +) = MissingKeyBackupPresenter( + buildMeta = buildMeta, + elementClassicConnection = elementClassicConnection, +) 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 92099180ec..31a835cb8c 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 @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_SESSION_ID 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.tests.testutils.WarmUpRule @@ -41,6 +42,20 @@ class LoginPasswordPresenterTest { } } + @Test + fun `present - initial login is in the first state and can be modified`() = runTest { + createLoginPasswordPresenter( + initialLogin = A_USER_NAME, + ).test { + val initialState = awaitItem() + assertThat(initialState.formState.login).isEqualTo(A_USER_NAME) + // Login can be changed + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME_2)) + val loginChangedState = awaitItem() + assertThat(loginChangedState.formState.login).isEqualTo(A_USER_NAME_2) + } + } + @Test fun `present - enter login and password`() = runTest { val authenticationService = FakeMatrixAuthenticationService( @@ -140,9 +155,11 @@ class LoginPasswordPresenterTest { } private fun createLoginPasswordPresenter( + initialLogin: String = "", authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), ): LoginPasswordPresenter = LoginPasswordPresenter( + initialLogin = initialLogin, authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, ) 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 1e971ef265..1fdfb7e070 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 @@ -16,7 +16,6 @@ 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.login.LoginHelper -import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState 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 @@ -83,16 +82,31 @@ class OnBoardingPresenterTest { ) presenter.test { val initialState = awaitItem() + assertThat(initialState.showBackButton).isFalse() assertThat(initialState.defaultAccountProvider).isNull() assertThat(initialState.canLoginWithQrCode).isFalse() assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canReportBug).isFalse() assertThat(initialState.isAddingAccount).isFalse() - assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse() val finalState = awaitItem() assertThat(finalState.canLoginWithQrCode).isTrue() - assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse() + } + } + + @Test + fun `present - initial state with back button`() = runTest { + val presenter = createPresenter( + params = OnBoardingNode.Params( + accountProvider = null, + loginHint = null, + showBackButton = true, + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.showBackButton).isTrue() + skipItems(1) } } @@ -162,6 +176,7 @@ class OnBoardingPresenterTest { params = OnBoardingNode.Params( accountProvider = ACCOUNT_PROVIDER_FROM_LINK, loginHint = null, + showBackButton = false, ), enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, @@ -184,6 +199,7 @@ class OnBoardingPresenterTest { params = OnBoardingNode.Params( accountProvider = ACCOUNT_PROVIDER_FROM_LINK, loginHint = null, + showBackButton = false, ), enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, @@ -206,6 +222,7 @@ class OnBoardingPresenterTest { params = OnBoardingNode.Params( accountProvider = ACCOUNT_PROVIDER_FROM_LINK, loginHint = null, + showBackButton = false, ), enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) }, @@ -233,6 +250,7 @@ class OnBoardingPresenterTest { params = OnBoardingNode.Params( accountProvider = A_HOMESERVER_URL, loginHint = A_LOGIN_HINT, + showBackButton = false, ), enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, @@ -265,7 +283,11 @@ class OnBoardingPresenterTest { } private fun createPresenter( - params: OnBoardingNode.Params = OnBoardingNode.Params(null, null), + params: OnBoardingNode.Params = OnBoardingNode.Params( + accountProvider = null, + loginHint = null, + showBackButton = false, + ), buildMeta: BuildMeta = aBuildMeta(), enterpriseService: EnterpriseService = FakeEnterpriseService(), wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), @@ -287,7 +309,6 @@ private fun createPresenter( onBoardingLogoResIdProvider = onBoardingLogoResIdProvider, sessionStore = sessionStore, accountProviderDataSource = accountProviderDataSource, - loginWithClassicPresenter = { aLoginWithClassicState() }, ) fun createLoginHelper( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt deleted file mode 100644 index 437e65f21d..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt +++ /dev/null @@ -1,214 +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. - */ - -@file:OptIn(ExperimentalCoroutinesApi::class) - -package io.element.android.features.login.impl.screens.onboarding.classic - -import com.google.common.truth.Truth.assertThat -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.test.A_SECRET -import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.libraries.sessionstorage.test.aSessionData -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class LoginWithClassicPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state - feature disabled - start is not invoked`() = runTest { - val presenter = createPresenter( - elementClassicConnection = FakeElementClassicConnection( - startResult = { - error("start should not be invoked when feature is disabled") - }, - ) - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.canLoginWithClassic).isFalse() - assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() - } - } - - @Test - fun `present - feature enabled - start is invoked`() = runTest { - val startResult = lambdaRecorder {} - val presenter = createPresenter( - elementClassicConnection = FakeElementClassicConnection( - startResult = startResult, - ), - isFeatureEnabled = true, - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.canLoginWithClassic).isFalse() - assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() - val finalState = awaitItem() - assertThat(finalState.canLoginWithClassic).isFalse() - } - startResult.assertions().isCalledOnce() - } - - @Test - fun `present - emit request data invokes the expected method`() = runTest { - val requestDataResult = lambdaRecorder {} - val presenter = createPresenter( - elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - requestDataResult = requestDataResult, - ), - isFeatureEnabled = true, - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.canLoginWithClassic).isFalse() - assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() - val nextState = awaitItem() - assertThat(nextState.canLoginWithClassic).isFalse() - nextState.eventSink(LoginWithClassicEvent.RefreshData) - } - requestDataResult.assertions().isCalledOnce() - } - - @Test - fun `present - start login with wrong state emits an error`() = runTest { - val presenter = createPresenter( - elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - ), - isFeatureEnabled = true, - ) - presenter.test { - skipItems(1) - val state = awaitItem() - state.eventSink(LoginWithClassicEvent.StartLoginWithClassic) - val errorState = awaitItem() - assertThat(errorState.loginWithClassicAction.isFailure()).isTrue() - } - } - - @Test - fun `present - start login with correct state - user cancel`() = runTest { - val elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - ) - val presenter = createPresenter( - elementClassicConnection = elementClassicConnection, - isFeatureEnabled = true, - ) - presenter.test { - skipItems(2) - elementClassicConnection.emitState( - ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET) - ) - val readyState = awaitItem() - assertThat(readyState.canLoginWithClassic).isTrue() - readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic) - val confirmingState = awaitItem() - assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue() - assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID) - confirmingState.eventSink(LoginWithClassicEvent.CloseDialog) - val finalState = awaitItem() - assertThat(finalState.loginWithClassicAction.isUninitialized()).isTrue() - } - } - - @Test - fun `present - start login with correct state - user confirms`() = runTest { - val elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - ) - val presenter = createPresenter( - elementClassicConnection = elementClassicConnection, - isFeatureEnabled = true, - ) - presenter.test { - skipItems(2) - elementClassicConnection.emitState( - ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET) - ) - val readyState = awaitItem() - assertThat(readyState.canLoginWithClassic).isTrue() - readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic) - val confirmingState = awaitItem() - assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue() - assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID) - confirmingState.eventSink(LoginWithClassicEvent.DoLoginWithClassic) - val loadingState = awaitItem() - assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.loginWithClassicAction.isSuccess()).isTrue() - } - } - - @Test - fun `present - cannot sign in if a session with the same account already exists`() = runTest { - val elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - ) - val presenter = createPresenter( - elementClassicConnection = elementClassicConnection, - isFeatureEnabled = true, - sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData( - sessionId = A_USER_ID.value, - ) - ) - ), - ) - presenter.test { - skipItems(2) - elementClassicConnection.emitState( - ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET) - ) - // No new item, because canLoginWithClassic is still false - } - } - - @Test - fun `present - cannot sign in if the feature is disabled`() = runTest { - val elementClassicConnection = FakeElementClassicConnection() - val presenter = createPresenter( - elementClassicConnection = elementClassicConnection, - isFeatureEnabled = false, - ) - presenter.test { - skipItems(1) - // Note: it should not happen IRL - elementClassicConnection.emitState( - ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET) - ) - // No new item, because canLoginWithClassic is still false - } - } -} - -private fun createPresenter( - elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), - sessionStore: SessionStore = InMemorySessionStore(), - isFeatureEnabled: Boolean = false, - featureFlagService: FeatureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.SignInWithClassic.key to isFeatureEnabled) - ), -) = LoginWithClassicPresenter( - elementClassicConnection = elementClassicConnection, - sessionStore = sessionStore, - featureFlagService = featureFlagService, -) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt new file mode 100644 index 0000000000..ba71ca131f --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.service + +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext + +interface ServiceBinder { + fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean + fun unbindService(conn: ServiceConnection) +} + +@ContributesBinding(AppScope::class) +class DefaultServiceBinder( + @ApplicationContext private val context: Context, +) : ServiceBinder { + override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean { + return context.bindService(service, conn, flags) + } + + override fun unbindService(conn: ServiceConnection) { + context.unbindService(conn) + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt new file mode 100644 index 0000000000..c8da7439a5 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt @@ -0,0 +1,54 @@ +/* + * 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.architecture.appyx + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler +import com.bumble.appyx.core.navigation.transition.TransitionDescriptor +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.NewRoot +import com.bumble.appyx.navmodel.backstack.operation.Replace +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider + +/** + * A TransitionHandler that uses fade transition when the operation is Replace or NewRoot, + * and slide transition for all other cases. + */ +private class FaderOrSliderTransitionHandler( + private val slider: ModifierTransitionHandler, + private val fader: ModifierTransitionHandler, +) : ModifierTransitionHandler() { + override fun createModifier( + modifier: Modifier, + transition: Transition, + descriptor: TransitionDescriptor + ): Modifier { + val operation = descriptor.operation + val useFader = operation is Replace || operation is NewRoot + val handler = if (useFader) fader else slider + return handler.createModifier(modifier, transition, descriptor) + } +} + +@Composable +fun rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler { + val slider = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val fader = rememberBackstackFader( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + return rememberDelegateTransitionHandler { + FaderOrSliderTransitionHandler(slider, fader) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt new file mode 100644 index 0000000000..f25174b7b0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt @@ -0,0 +1,79 @@ +/* + * 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.designsystem.components.avatar + +import android.graphics.Bitmap +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar +import timber.log.Timber + +// For user avatar only. +@Composable +fun BitmapAvatar( + avatarData: AvatarData, + bitmap: Bitmap?, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val avatarShape = AvatarType.User.avatarShape() + when { + bitmap == null -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = null, + modifier = modifier, + contentDescription = contentDescription, + ) + else -> { + val size = avatarData.size.dp + SubcomposeAsyncImage( + model = bitmap, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = modifier + .size(size) + .clip(avatarShape) + ) { + val collectedState by painter.state.collectAsState() + when (val state = collectedState) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + is AsyncImagePainter.State.Error -> { + SideEffect { + Timber.e( + state.result.throwable, + "Error loading avatar $state\n${state.result}" + ) + } + InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = null, + contentDescription = contentDescription, + ) + } + else -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = null, + contentDescription = contentDescription, + ) + } + } + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt similarity index 56% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt index 5fae0afdd5..d094019db2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt @@ -5,11 +5,14 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.login.impl.screens.onboarding.classic +package io.element.android.libraries.matrix.api.auth -import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.UserId -class ConfirmingLoginWithElementClassic( +data class ElementClassicSession( val userId: UserId, -) : AsyncAction.Confirming + val homeserverUrl: String?, + val secrets: String?, + val roomKeysVersion: String?, + val doesContainBackupKey: Boolean, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index 1c574ad467..7c82668242 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId interface MatrixAuthenticationService { /** @@ -52,6 +53,20 @@ interface MatrixAuthenticationService { */ suspend fun cancelOidcLogin(): Result + /** + * Set the existing data about Element Classic session, if any. + */ + fun setElementClassicSession(session: ElementClassicSession?) + + /** + * Check if the provided secrets from Element Classic session contain a key backup. + */ + fun doSecretsContainBackupKey( + userId: UserId, + secrets: String, + backupInfo: String, + ): Boolean + /** * Attempt to login using the [callbackUrl] provided by the Oidc page. */ diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt new file mode 100644 index 0000000000..eb97ce0d8c --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt @@ -0,0 +1,20 @@ +/* + * 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.core + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UserIdTest { + @Test + fun `valid user id`() { + val userId = UserId("@alice:example.org") + assertThat(userId.extractedDisplayName).isEqualTo("alice") + assertThat(userId.domainName).isEqualTo("example.org") + } +} 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 7cd9fbedf5..9fe7c7cd1f 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 @@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.AuthenticationException +import io.element.android.libraries.matrix.api.auth.ElementClassicSession import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails @@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync import io.element.android.libraries.matrix.impl.RustMatrixClientFactory @@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData import org.matrix.rustcomponents.sdk.QrCodeDecodeException import org.matrix.rustcomponents.sdk.QrLoginProgress import org.matrix.rustcomponents.sdk.QrLoginProgressListener +import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId import timber.log.Timber import uniffi.matrix_sdk.OAuthAuthorizationData import kotlin.time.Duration.Companion.seconds @@ -64,6 +67,9 @@ class RustMatrixAuthenticationService( private val passphraseGenerator: PassphraseGenerator, private val oidcConfigurationProvider: OidcConfigurationProvider, ) : MatrixAuthenticationService { + // Any existing Element Classic session that we want to try to import secrets from during login. + private var elementClassicSession: ElementClassicSession? = null + // Passphrase which will be used for new sessions. Existing sessions will use the passphrase // stored in the SessionData. private val pendingPassphrase = getDatabasePassphrase() @@ -138,9 +144,15 @@ class RustMatrixAuthenticationService( runCatchingExceptions { val client = currentClient ?: error("You need to call `setHomeserver()` first") val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") - client.login(username, password, "Element X Android", null) + client.login( + username = username, + password = password, + initialDeviceName = "Element X Android", + deviceId = null, + ) // Ensure that the user is not already logged in with the same account ensureNotAlreadyLoggedIn(client) + tryToImportSecretForElementClassicSession(client) val sessionData = client.session() .toSessionData( isTokenValid = true, @@ -162,6 +174,53 @@ class RustMatrixAuthenticationService( } } + private suspend fun tryToImportSecretForElementClassicSession(client: Client) { + elementClassicSession + ?.takeIf { + // Note: the SDK will also do this check + it.userId.value == client.userId() + } + ?.let { + val secrets = it.secrets + val roomKeysVersion = it.roomKeysVersion + if (secrets == null || roomKeysVersion == null) { + Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import") + } else { + Timber.d("Trying to import secrets for Element Classic session ${it.userId}") + runCatchingExceptions { + SecretsBundleWithUserId.fromStr( + userId = it.userId.value, + bundle = secrets, + backupInfo = roomKeysVersion, + ).use { secretsBundle -> + client.encryption().importSecretsBundle(secretsBundle) + } + }.onFailure { failure -> + Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}") + } + } + } + } + + override fun doSecretsContainBackupKey( + userId: UserId, + secrets: String, + backupInfo: String, + ): Boolean { + return try { + SecretsBundleWithUserId.fromStr( + userId = userId.value, + bundle = secrets, + backupInfo = backupInfo, + ).use { secretsBundle -> + secretsBundle.containsBackupKey() + } + } catch (failure: Exception) { + Timber.e(failure, "Failed to parse secrets for Element Classic session $userId") + false + } + } + override suspend fun importCreatedSession(externalSession: ExternalSession): Result = withContext(coroutineDispatchers.io) { runCatchingExceptions { @@ -233,6 +292,10 @@ class RustMatrixAuthenticationService( } } + override fun setElementClassicSession(session: ElementClassicSession?) { + elementClassicSession = session + } + /** * callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters). */ @@ -241,14 +304,15 @@ class RustMatrixAuthenticationService( runCatchingExceptions { val client = currentClient ?: error("You need to call `setHomeserver()` first") val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") - client.loginWithOidcCallback(callbackUrl) - + client.loginWithOidcCallback( + callbackUrl = callbackUrl, + ) // Free the pending data since we won't use it to abort the flow anymore pendingOAuthAuthorizationData?.close() pendingOAuthAuthorizationData = null - // Ensure that the user is not already logged in with the same account ensureNotAlreadyLoggedIn(client) + tryToImportSecretForElementClassicSession(client) val sessionData = client.session().toSessionData( isTokenValid = true, loginType = LoginType.OIDC, 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 c4acccb55c..238ad2663d 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 @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.test.auth import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.ElementClassicSession import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails @@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService( lambdaRecorder Unit, Result> { _, _ -> Result.success(A_SESSION_ID) }, private val importCreatedSessionLambda: (ExternalSession) -> Result = { lambdaError() }, private val setHomeserverResult: (String) -> Result = { lambdaError() }, + private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() }, + private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() }, ) : MatrixAuthenticationService { private var oidcError: Throwable? = null private var oidcCancelError: Throwable? = null @@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService( fun givenMatrixClient(matrixClient: MatrixClient) { this.matrixClient = matrixClient } + + override fun setElementClassicSession(session: ElementClassicSession?) { + setElementClassicSessionResult(session) + } + + override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean { + return doSecretsContainBackupKeyResult(userId, secrets, backupInfo) + } } diff --git a/tools/localazy/checkForbiddenTerms.py b/tools/localazy/checkForbiddenTerms.py index e190fcea68..123246ffd0 100755 --- a/tools/localazy/checkForbiddenTerms.py +++ b/tools/localazy/checkForbiddenTerms.py @@ -31,6 +31,9 @@ forbiddenTerms = { # We explicitly want to mention Element Pro in these 2: "screen_change_server_error_element_pro_required_title", "screen_change_server_error_element_pro_required_message", + # Contains "Element Classic" + "screen_missing_key_backup_open_element_classic", + "screen_missing_key_backup_step_1", ] } diff --git a/tools/localazy/config.json b/tools/localazy/config.json index d3e44b7c08..b38268e5f7 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -170,6 +170,8 @@ "name" : ":features:login:impl", "includeRegex" : [ "screen_onboarding_.*", + "screen\\.onboarding\\..*", + "screen\\.missing_key_backup\\..*", "screen_login_.*", "screen_server_confirmation_.*", "screen_change_server_.*", From 62f2251adbf003471045214c80a8eaee3439b808 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 9 Apr 2026 13:15:51 +0000 Subject: [PATCH 06/53] Update screenshots --- ....classic.loginwithclassic_LoginWithClassicView_Day_0_en.png | 3 +++ ....classic.loginwithclassic_LoginWithClassicView_Day_1_en.png | 3 +++ ...lassic.loginwithclassic_LoginWithClassicView_Night_0_en.png | 3 +++ ...lassic.loginwithclassic_LoginWithClassicView_Night_1_en.png | 3 +++ ....classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png | 3 +++ ...lassic.missingkeybackup_MissingKeyBackupView_Night_0_en.png | 3 +++ ...tures.login.impl.screens.classic.root_RootView_Day_0_en.png | 3 +++ ...res.login.impl.screens.classic.root_RootView_Night_0_en.png | 3 +++ ...s.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png | 3 +++ ...login.impl.screens.onboarding_OnBoardingView_Night_8_en.png | 3 +++ 10 files changed, 30 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png new file mode 100644 index 0000000000..adbcd16ec1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d861dd7397c0e15091e022d956c1955d86529fa0cc39e08c3c645d91e5023e0e +size 77677 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png new file mode 100644 index 0000000000..df7c95510d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0acaebca757642346f3381601f044a55d02749575150364e232f772ba0167e1 +size 73811 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png new file mode 100644 index 0000000000..7bed006a97 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54c841ffefb3d053bb74e6f88a9aa7cff5d39b4854c92de3b507552a9a4226bc +size 68693 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png new file mode 100644 index 0000000000..94a1749517 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba1f4395b4c32e7edead84b1e22957cc8f973121207d7000419a8f4c314f5b8 +size 65936 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png new file mode 100644 index 0000000000..4a5a13a36a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef35fd3f5346870f11120a37c9db969453b7594bf9a0ccc71fe43e7fdade488 +size 62532 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png new file mode 100644 index 0000000000..1b69f8f6a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1501c2591f7df68404285770b1dad67360dddce074d4ce1c71223ea0baa0d1e4 +size 60873 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png new file mode 100644 index 0000000000..e3e5480add --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e73b036458ee32e207f711cf6656fe7646b23d3d9e096e62932c828dd53189 +size 5244 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png new file mode 100644 index 0000000000..6582264383 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518818c549548b6304d2960242ce7251bb609fa439928539a7556c33223ca8ba +size 5251 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png new file mode 100644 index 0000000000..b5f0eb7fcf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5015f504040a0141d40bc14bf8a3a3be43c9c95a3702a6dc53bb253746e5a3aa +size 311553 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png new file mode 100644 index 0000000000..4669c0b972 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8972af96ba5624f92c826ef0e20595b6193a44b6b0a0ea03cb133c516a93a90e +size 391678 From 73e1a092d22d348af8091f851c5744a34ab8b7c5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Apr 2026 11:15:42 +0200 Subject: [PATCH 07/53] Ignore secrets when the bundle does not contain the room keys version. --- .../impl/classic/ElementClassicConnection.kt | 12 ++- .../DefaultElementClassicConnectionTest.kt | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index 5e838094d3..e360aff376 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -319,8 +319,16 @@ class DefaultElementClassicConnection( if (userId == null) { ElementClassicConnectionState.ElementClassicReadyNoSession } else { - val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } - val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR)?.takeIf { it.isNotEmpty() } + var secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } + val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR) + .also { + if (secrets != null && it == null) { + Timber.tag(loggerTag.value).w("Room keys version is null, outdated version of Element Classic, ignore secrets") + // In this case, just ignore the secrets, the SDK will not accept them anyway + secrets = null + } + } + ?.takeIf { it.isNotEmpty() } val homeserverUrl = getString(KEY_HOMESERVER_URL_STR)?.takeIf { it.isNotEmpty() } val displayName = getString(KEY_USER_DISPLAY_NAME_STR)?.takeIf { it.isNotEmpty() } val doesContainBackupKey = secrets != null && diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt index 1c76a93650..8ea1b2e3d3 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -270,6 +270,82 @@ class DefaultElementClassicConnectionTest { } } + @Test + fun `when session is received with secret but without room keys version Element Classic is outdated and the secret is ignored`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, A_HOMESERVER_URL) + putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET) + putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, null) + putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, A_USER_NAME) + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = A_HOMESERVER_URL, + secrets = null, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = A_USER_NAME, + avatar = null, + ) + ) + } + } + + @Test + fun `when session is received with secret but with empty room keys version, doesContainBackupKey is false`() = runTest { + val connection = createDefaultElementClassicConnection( + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( + checkResult = { Result.success(true) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + // Simulate receiving a session from Element Classic + connection.onSessionReceived( + Bundle().apply { + putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value) + putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, A_HOMESERVER_URL) + putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET) + putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, "") + putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, A_USER_NAME) + } + ) + assertThat(awaitItem()).isEqualTo( + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = A_USER_ID, + homeserverUrl = A_HOMESERVER_URL, + secrets = A_SECRET, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = A_USER_NAME, + avatar = null, + ) + ) + } + } + @Test fun `when session is received with empty data, and homeserver is supported, ElementClassicReady is emitted`() = runTest { val connection = createDefaultElementClassicConnection( From f5e1cbef38df6c151acabbc7096a17990b6a25ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Apr 2026 15:25:37 +0200 Subject: [PATCH 08/53] Fix navigation issue. Ensure that the timeout has effect only in Idle state. --- .../screens/classic/ClassicFlowNodeHelper.kt | 87 +++++++++---------- .../classic/ClassicFlowNodeHelperTest.kt | 29 ++++--- 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt index f719fe5083..cae4f834d0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt @@ -12,67 +12,60 @@ import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.features.login.impl.classic.ElementClassicConnectionState import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.flowOf @Inject class ClassicFlowNodeHelper( private val elementClassicConnection: ElementClassicConnection, private val sessionStore: SessionStore, ) { - // Ensure user is not stuck on the loading screen. - // If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds. - private val timeoutFLow = flow { - emit(false) - delay(5_000) - emit(true) - } - + @OptIn(ExperimentalCoroutinesApi::class) fun navigationEventFlow(): Flow { - return combine( - timeoutFLow, - elementClassicConnection.stateFlow - .distinctUntilChangedBy { - // Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar - if (it is ElementClassicConnectionState.ElementClassicReady) { - it.copy(avatar = null) - } else { - it - } - }, - sessionStore.sessionsFlow().toUserListFlow() - // Take only 1 emission of the sessions, else when the user actually logged in it will trigger a navigation to OnBoarding. - .take(1), - ) { timeout, elementClassicConnectionState, existingSessions -> - when (elementClassicConnectionState) { - ElementClassicConnectionState.Idle -> { - if (timeout) { - NavigationEvent.NavigateToOnBoarding - } else { - NavigationEvent.Idle - } + return elementClassicConnection.stateFlow + .distinctUntilChangedBy { + // Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar + if (it is ElementClassicConnectionState.ElementClassicReady) { + it.copy(avatar = null) + } else { + it } - ElementClassicConnectionState.ElementClassicNotFound, - ElementClassicConnectionState.ElementClassicReadyNoSession, - is ElementClassicConnectionState.Error -> { - NavigationEvent.NavigateToOnBoarding - } - is ElementClassicConnectionState.ElementClassicReady -> { - if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) { - NavigationEvent.NavigateToOnBoarding - } else { - // 2 cases when this can be run: - // First time this screen will be displayed - // Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic), - // and the app is resuming. - NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId) + } + .flatMapLatest { elementClassicConnectionState -> + when (elementClassicConnectionState) { + ElementClassicConnectionState.Idle -> { + // Ensure user is not stuck on the loading screen. + // If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds. + flow { + emit(NavigationEvent.Idle) + delay(5_000) + emit(NavigationEvent.NavigateToOnBoarding) + } + } + ElementClassicConnectionState.ElementClassicNotFound, + ElementClassicConnectionState.ElementClassicReadyNoSession, + is ElementClassicConnectionState.Error -> { + flowOf(NavigationEvent.NavigateToOnBoarding) + } + is ElementClassicConnectionState.ElementClassicReady -> { + val existingSessions = sessionStore.sessionsFlow().toUserListFlow().first() + if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) { + flowOf(NavigationEvent.NavigateToOnBoarding) + } else { + // 2 cases when this can be run: + // First time this screen will be displayed + // Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic), + // and the app is resuming. + flowOf(NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId)) + } } } } - } } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt index 1184bb91af..017fd1b633 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -38,16 +39,6 @@ class ClassicFlowNodeHelperTest { @get:Rule val warmUpRule = WarmUpRule() - @Test - fun `initial state`() = runTest { - createHelper() - .navigationEventFlow() - .test { - val initialState = awaitItem() - assertThat(initialState).isEqualTo(NavigationEvent.Idle) - } - } - @Test fun `after a few seconds in Idle, NavigateToOnBoarding is emitted`() = runTest { createHelper() @@ -57,6 +48,8 @@ class ClassicFlowNodeHelperTest { assertThat(initialState).isEqualTo(NavigationEvent.Idle) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -82,6 +75,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -100,6 +95,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -118,6 +115,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -136,6 +135,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -154,6 +155,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -178,6 +181,7 @@ class ClassicFlowNodeHelperTest { avatar = createBitmap(1, 1) ) ) + advanceTimeBy(10_000) expectNoEvents() } } @@ -211,6 +215,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -236,6 +242,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -264,6 +272,7 @@ class ClassicFlowNodeHelperTest { sessionId = A_USER_ID.value, ) ) + advanceTimeBy(10_000) expectNoEvents() } } From d39e1e90034f0817172000cbde4425389b781d53 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:56:36 +0000 Subject: [PATCH 09/53] Update metro to v0.13.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7902ee0cfa..80334fd02a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ haze = "1.7.2" dependencyAnalysis = "3.6.1" # DI -metro = "0.13.1" +metro = "0.13.2" # Auto service autoservice = "1.1.1" From d259eb663468422dc7aa8850d0ab4b5e4699d8a9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:05:30 +0200 Subject: [PATCH 10/53] Update wysiwyg to v2.41.3 (#6572) 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 7902ee0cfa..7ab0cc6ee3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ showkase = "1.0.5" # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" sqldelight = "2.3.2" -wysiwyg = "2.41.1" +wysiwyg = "2.41.3" telephoto = "0.19.0" haze = "1.7.2" From 1962b965fde31fa629e3ebdb9303a9cd0c29a175 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 10:12:03 +0200 Subject: [PATCH 11/53] Improve log and reduce severity. --- .../impl/classic/ElementClassicConnection.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index e360aff376..393762fed2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -107,7 +107,7 @@ class DefaultElementClassicConnection( } override fun start() { - Timber.tag(loggerTag.value).w("start()") + Timber.tag(loggerTag.value).d("start()") coroutineScope.launch { // Establish a connection with the service. We use an explicit // class name because there is no reason to be able to let other @@ -130,7 +130,7 @@ class DefaultElementClassicConnection( } override fun stop() { - Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") + Timber.tag(loggerTag.value).d("stop(): Unbinding (bound=$bound)") if (bound) { // Detach our existing connection. serviceBinder.unbindService(serviceConnection) @@ -142,13 +142,12 @@ class DefaultElementClassicConnection( } override fun requestSession() { - Timber.tag(loggerTag.value).w("requestSession()") + Timber.tag(loggerTag.value).d("requestSession()") coroutineScope.launch { val finalMessenger = messenger if (finalMessenger == null) { - Timber.tag(loggerTag.value).w("The messenger is null, can't request data") + Timber.tag(loggerTag.value).d("The messenger is null, can't request data") // Do not emit error, else the regular on boarding flow will be displayed - // emitState(ElementClassicConnectionState.Error("The messenger is null, can't request data")) } else { try { // Get the data @@ -168,7 +167,7 @@ class DefaultElementClassicConnection( } override fun requestAvatar(userId: UserId) { - Timber.tag(loggerTag.value).w("requestAvatar()") + Timber.tag(loggerTag.value).d("requestAvatar()") coroutineScope.launch { val finalMessenger = messenger if (finalMessenger == null) { @@ -265,7 +264,7 @@ class DefaultElementClassicConnection( if (isCompatible) { Timber.tag(loggerTag.value).d("Found compatible homeserver URL: %s", url) } else { - Timber.tag(loggerTag.value).w("Homeserver URL is not compatible: %s", url) + Timber.tag(loggerTag.value).d("Homeserver URL is not compatible: %s", url) } isCompatible } @@ -334,6 +333,16 @@ class DefaultElementClassicConnection( val doesContainBackupKey = secrets != null && roomKeysVersion != null && matrixAuthenticationService.doSecretsContainBackupKey(userId, secrets, roomKeysVersion) + Timber.tag(loggerTag.value).d( + buildString { + append("Receiving session $userId ($displayName) from Element Classic, with secrets: ") + append(secrets != null) + append(", with roomKeysVersion: ") + append(roomKeysVersion != null) + append(", with valid backup key: ") + append(doesContainBackupKey) + } + ) ElementClassicConnectionState.ElementClassicReady( elementClassicSession = ElementClassicSession( userId = userId, From 53c20f3f25a870d4d63fdad6b5c69702788dbc7b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 10:22:13 +0200 Subject: [PATCH 12/53] Make elementClassicComponent a val. --- .../features/login/impl/classic/ElementClassicConnection.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index 393762fed2..55bc5e1fd6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -114,7 +114,7 @@ class DefaultElementClassicConnection( // applications replace our component. try { val intentService = Intent() - intentService.setComponent(getElementClassicComponent()) + intentService.setComponent(elementClassicComponent) if (serviceBinder.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { Timber.tag(loggerTag.value).d("Binding returned true") } else { @@ -304,7 +304,7 @@ class DefaultElementClassicConnection( mutableStateFlow.emit(state) } - private fun getElementClassicComponent() = ComponentName( + private val elementClassicComponent = ComponentName( BuildConfig.elementClassicPackage, ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, ) From e1d175c1ac2a2393034fc5c7597e39d28cf5e572 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 11:38:52 +0200 Subject: [PATCH 13/53] Let Announcement.Fullscreen be an enum. --- .../android/features/announcement/api/Announcement.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index 1d6f357ca8..d743ae4cd6 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -12,9 +12,8 @@ import androidx.compose.runtime.Immutable @Immutable sealed interface Announcement { - @Immutable - sealed interface Fullscreen : Announcement { - data object Space : Fullscreen + enum class Fullscreen : Announcement { + Space, } data object NewNotificationSound : Announcement From 5ec4518409d6b79c9b5b0c1624241878e729ea1a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 11:40:53 +0200 Subject: [PATCH 14/53] Restore previous key. --- .../announcement/impl/store/DefaultAnnouncementStore.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index d24e9ed26e..5a6c135247 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFac import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") @ContributesBinding(AppScope::class) @@ -52,6 +53,6 @@ class DefaultAnnouncementStore( } private fun Announcement.toKey() = when (this) { - is Announcement.Fullscreen -> intPreferencesKey("fullscreen_" + this::class.simpleName) + Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey } From d7e3c2df93957ddcf7a82ab01018342999628afb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 12:16:09 +0200 Subject: [PATCH 15/53] Add missing test for `AnnouncementEvent.Continue` --- .../impl/AnnouncementPresenterTest.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt index bcfd80942d..c37f49fad8 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt @@ -14,6 +14,7 @@ import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Test @@ -44,6 +45,28 @@ class AnnouncementPresenterTest { assertThat(finalState.announcement).isNull() } } + + @Test + fun `present - continue event will mark the announcement as Shown`() = runTest { + val store = InMemoryAnnouncementStore() + val presenter = createAnnouncementPresenter( + announcementStore = store, + ) + presenter.test { + val state = awaitItem() + assertThat(state.announcement).isNull() + store.setAnnouncementStatus(Announcement.Fullscreen.Space, AnnouncementStatus.Show) + val statusShow = store.announcementStatusFlow(Announcement.Fullscreen.Space).first() + assertThat(statusShow).isEqualTo(AnnouncementStatus.Show) + val updatedState = awaitItem() + assertThat(updatedState.announcement).isEqualTo(Announcement.Fullscreen.Space) + updatedState.eventSink(AnnouncementEvent.Continue(Announcement.Fullscreen.Space)) + val statusShown = store.announcementStatusFlow(Announcement.Fullscreen.Space).first() + assertThat(statusShown).isEqualTo(AnnouncementStatus.Shown) + val finalState = awaitItem() + assertThat(finalState.announcement).isNull() + } + } } private fun createAnnouncementPresenter( From 76de9db94e14cc76ab53d2c42b85e0023e60b712 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 12:21:44 +0200 Subject: [PATCH 16/53] Move `val`s at the top of the class. --- .../impl/classic/ElementClassicConnection.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index 55bc5e1fd6..3c4dd4de3c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -80,6 +80,14 @@ class DefaultElementClassicConnection( // Flag indicating whether we have called bind on the service. private var bound: Boolean = false + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() + + private val elementClassicComponent = ComponentName( + BuildConfig.elementClassicPackage, + ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, + ) + /** * Class for interacting with the main interface of the service. */ @@ -192,9 +200,6 @@ class DefaultElementClassicConnection( } } - private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) - override val stateFlow = mutableStateFlow.asStateFlow() - /** * Handler of incoming messages from service. */ @@ -304,11 +309,6 @@ class DefaultElementClassicConnection( mutableStateFlow.emit(state) } - private val elementClassicComponent = ComponentName( - BuildConfig.elementClassicPackage, - ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, - ) - private fun Bundle.toElementClassicConnectionState(): ElementClassicConnectionState { val error = getString(KEY_ERROR_STR) return if (error != null) { From 83c4fa827855425c9b1a22bb2d0e8fba5caa141c Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 14 Apr 2026 14:45:35 +0200 Subject: [PATCH 17/53] feat: Default to camera muted when joining ongoing voice call --- .../home/impl/components/RoomSummaryRow.kt | 5 +- .../datasource/RoomListRoomSummaryFactory.kt | 6 ++ .../home/impl/model/RoomListRoomSummary.kt | 2 + .../impl/model/RoomListRoomSummaryProvider.kt | 11 +++ .../roomcall/impl/RoomCallStatePresenter.kt | 14 ++- .../impl/RoomCallStatePresenterTest.kt | 96 +++++++++++++++++++ 6 files changed, 131 insertions(+), 3 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index e2598a9e1c..f541417104 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -60,6 +60,7 @@ import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.unreadIndicator +import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.ui.components.InviteSenderView import io.element.android.libraries.matrix.ui.model.InviteSender @@ -349,6 +350,7 @@ private fun MessagePreviewAndIndicatorRow( if (room.hasRoomCall) { OnGoingCallIcon( color = tint, + isAudio = room.activeCallIntent == CallIntent.AUDIO ) } if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { @@ -398,10 +400,11 @@ private fun InviteNameAndIndicatorRow( @Composable private fun OnGoingCallIcon( color: Color, + isAudio: Boolean ) { Icon( modifier = Modifier.size(16.dp), - imageVector = CompoundIcons.VideoCallSolid(), + imageVector = if (isAudio) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(), contentDescription = stringResource(CommonStrings.a11y_notifications_ongoing_call), tint = color, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt index d723d1a424..26054d7e56 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +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.isDm import io.element.android.libraries.matrix.api.roomlist.LatestEventValue @@ -50,6 +51,11 @@ class RoomListRoomSummaryFactory( avatarData = avatarData, userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, hasRoomCall = roomInfo.hasRoomCall, + activeCallIntent = when (val consensus = roomInfo.activeCallIntentConsensus) { + is CallIntentConsensus.Full -> consensus.callIntent + is CallIntentConsensus.Partial -> consensus.callIntent + CallIntentConsensus.None -> null + }, isDirect = roomInfo.isDirect, isFavorite = roomInfo.isFavorite, inviteSender = roomInfo.inviter?.toInviteSender(), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt index a59e444455..628d0d0a9b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt @@ -13,6 +13,7 @@ import io.element.android.features.invite.api.InviteData import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.ui.model.InviteSender import kotlinx.collections.immutable.ImmutableList @@ -33,6 +34,7 @@ data class RoomListRoomSummary( val avatarData: AvatarData, val userDefinedNotificationMode: RoomNotificationMode?, val hasRoomCall: Boolean, + val activeCallIntent: CallIntent?, val isDirect: Boolean, val isDm: Boolean, val isFavorite: Boolean, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt index 400decff6f..eefb2d6484 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.ui.model.InviteSender import kotlinx.collections.immutable.toImmutableList @@ -132,6 +133,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider RoomCallState.StandBy( canStartCall = canJoinCall, @@ -70,3 +71,12 @@ class RoomCallStatePresenter( return callState } } + +fun CallIntentConsensus.isAudio(): Boolean { + val intent = when (this) { + is CallIntentConsensus.Full -> callIntent + is CallIntentConsensus.Partial -> callIntent + is CallIntentConsensus.None -> return false + } + return intent == CallIntent.AUDIO +} diff --git a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt index 0a561ad59a..4c6fcf8e59 100644 --- a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt +++ b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt @@ -14,6 +14,8 @@ import io.element.android.features.call.api.CurrentCallService import io.element.android.features.call.test.FakeCurrentCallService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.matrix.api.notification.CallIntent +import io.element.android.libraries.matrix.api.room.CallIntentConsensus import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.test.room.FakeBaseRoom @@ -188,6 +190,100 @@ class RoomCallStatePresenterTest { } } + @Test + fun `present - active call with audio Intent`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(true), + ).apply { + givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeCallIntentConsensus = CallIntentConsensus.Full(CallIntent.AUDIO), + activeRoomCallParticipants = emptyList(), + ) + ) + } + ) + val presenter = createRoomCallStatePresenter( + joinedRoom = room, + currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))), + ) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isAudioCall = true, + isUserInTheCall = false, + isUserLocallyInTheCall = true, + ) + ) + } + } + + @Test + fun `present - active call with partial audio Intent`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(true), + ).apply { + givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeCallIntentConsensus = CallIntentConsensus.Partial(CallIntent.AUDIO, 1, 4), + ) + ) + } + ) + val presenter = createRoomCallStatePresenter( + joinedRoom = room, + currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))), + ) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isAudioCall = true, + isUserInTheCall = false, + isUserLocallyInTheCall = true, + ) + ) + } + } + + @Test + fun `present - active call with no intent defaults to Audio`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(true), + ).apply { + givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeCallIntentConsensus = CallIntentConsensus.None, + ) + ) + } + ) + val presenter = createRoomCallStatePresenter( + joinedRoom = room, + currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))), + ) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isAudioCall = false, + isUserInTheCall = false, + isUserLocallyInTheCall = true, + ) + ) + } + } + @Test fun `present - user leaves the call`() = runTest { val room = FakeJoinedRoom( From 107524b01dec1a7c3d7612b815792587514c3b36 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 14 Apr 2026 13:18:21 +0000 Subject: [PATCH 18/53] Update screenshots --- .../features.home.impl.components_RoomSummaryRow_Day_38_en.png | 3 +++ ...eatures.home.impl.components_RoomSummaryRow_Night_38_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_38_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_38_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_38_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_38_en.png new file mode 100644 index 0000000000..473fd3f275 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_38_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26071a6b16f4446526f6abf28274580eb0f385bd0e74f4f2b0479da5f5d4f6f2 +size 12914 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_38_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_38_en.png new file mode 100644 index 0000000000..14c08f0b62 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_38_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ddef7c50d6a791c6a32bae8506a999b198bc601b70b6ffb5e5e7a8d5a2f1543 +size 12869 From b033f2e1291b03f8b7a1c7d1d638bda0c608c771 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 14 Apr 2026 15:20:56 +0200 Subject: [PATCH 19/53] fixup: test compilation pb --- .../features/home/impl/model/RoomListBaseRoomSummaryTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt index 28e7051a55..63f1ecebd7 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt @@ -101,6 +101,7 @@ internal fun createRoomListRoomSummary( displayType = displayType, userDefinedNotificationMode = userDefinedNotificationMode, hasRoomCall = false, + activeCallIntent = null, isDirect = false, isFavorite = isFavorite, canonicalAlias = null, From 7a7a5a68b9f5d193e7095f77da50aa9ec948525e Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 14 Apr 2026 15:52:41 +0200 Subject: [PATCH 20/53] Fix `isInAirGappedEnvironment` check for older APIs (#6573) * Fix `isInAirGappedEnvironment` check for older APIs: use `networkCapabilities.hasCapability` instead of `networkCapabilities.capabilities.contains`, which only works on Android 12 and newer versions * Check for air-gapped env in the FOSS app too: this unifies the notification behaviour on EXA and Element Pro --- .../networkmonitor/impl/DefaultNetworkMonitor.kt | 12 +----------- .../SyncPendingNotificationsRequestBuilder.kt | 1 - ...aultSyncPendingNotificationsRequestBuilderTest.kt | 6 ++++++ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt index 7cffa057bc..6981d2c6af 100644 --- a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt @@ -15,13 +15,11 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest -import android.os.Build import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus -import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -44,7 +42,6 @@ import java.util.concurrent.atomic.AtomicInteger class DefaultNetworkMonitor( @ApplicationContext context: Context, @AppCoroutineScope appCoroutineScope: CoroutineScope, - private val buildMeta: BuildMeta, ) : NetworkMonitor { private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) @@ -76,17 +73,10 @@ class DefaultNetworkMonitor( } override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { - if (!buildMeta.isEnterpriseBuild) { - // The air-gapped environment detection is only relevant for the enterprise build. - return - } - if (network.networkHandle == connectivityManager.activeNetwork?.networkHandle) { // If the network doesn't have the NET_CAPABILITY_VALIDATED capability, it means that the network is not able to reach the internet // (according to Google), which is a common case in air-gapped environments. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - isInAirGappedEnvironment.value = !networkCapabilities.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } + isInAirGappedEnvironment.value = !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index bdb8389feb..4c1da42660 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -76,7 +76,6 @@ class DefaultSyncPendingNotificationsRequestBuilder( .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) // If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all. - // Note this will always be false for FOSS, since the feature is only enabled in Element Pro. if (networkMonitor.isInAirGappedEnvironment.first()) { Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt index 796d5d192f..e65bea20a9 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt @@ -80,6 +80,9 @@ class DefaultSyncPendingNotificationsRequestBuilderTest { sessionId = A_SESSION_ID, sdkVersion = 33, isInAirGapEnvironment = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf( + FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to true + )), ) val results = request.build() @@ -100,6 +103,9 @@ class DefaultSyncPendingNotificationsRequestBuilderTest { sessionId = A_SESSION_ID, sdkVersion = 33, isInAirGapEnvironment = true, + featureFlagService = FakeFeatureFlagService(initialState = mapOf( + FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to true + )), ) val results = request.build() From 29e4771b22ead2e5bf79fd9aeac5f2fd73cda8e0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:56:35 +0000 Subject: [PATCH 21/53] Update dependency com.google.testparameterinjector:test-parameter-injector to v1.22 --- 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 7ab0cc6ee3..2288b09220 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -165,7 +165,7 @@ test_mockk = "io.mockk:mockk:1.14.9" test_konsist = "com.lemonappdev:konsist:0.17.3" test_turbine = "app.cash.turbine:turbine:1.2.1" test_truth = "com.google.truth:truth:1.4.5" -test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.21" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" test_robolectric = "org.robolectric:robolectric:4.16.1" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.8.2" From fd3c4c2b2b8eff400541550ba237b25cbb93a54b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 16:51:00 +0200 Subject: [PATCH 22/53] Refresh Element Classic state each time ClassicFlowNode is resumed. This ensure that Element X is always up to date regarding Element Classic state. --- .../impl/classic/ElementClassicConnection.kt | 28 ++++++++++++----- .../impl/screens/classic/ClassicFlowNode.kt | 6 ++++ .../screens/classic/ClassicFlowNodeHelper.kt | 4 +++ .../loginwithclassic/LoginWithClassicEvent.kt | 1 - .../LoginWithClassicPresenter.kt | 7 ----- .../loginwithclassic/LoginWithClassicView.kt | 6 ---- .../missingkeybackup/MissingKeyBackupEvent.kt | 12 -------- .../MissingKeyBackupPresenter.kt | 20 ------------- .../missingkeybackup/MissingKeyBackupState.kt | 1 - .../MissingKeyBackupStateProvider.kt | 2 -- .../missingkeybackup/MissingKeyBackupView.kt | 7 ----- .../classic/FakeElementClassicConnection.kt | 3 -- .../LoginWithClassicPresenterTest.kt | 30 ------------------- .../MissingKeyBackupPresenterTest.kt | 22 -------------- 14 files changed, 30 insertions(+), 119 deletions(-) delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index 3c4dd4de3c..dfddd1d496 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -43,7 +43,6 @@ interface ElementClassicConnection { fun start() fun stop() fun requestSession() - fun requestAvatar(userId: UserId) val stateFlow: StateFlow } @@ -174,7 +173,7 @@ class DefaultElementClassicConnection( } } - override fun requestAvatar(userId: UserId) { + private fun requestAvatar(userId: UserId) { Timber.tag(loggerTag.value).d("requestAvatar()") coroutineScope.launch { val finalMessenger = messenger @@ -225,6 +224,11 @@ class DefaultElementClassicConnection( coroutineScope.launch { val updatedState = ensureHomeserverIsSupported(state) emitState(updatedState) + val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId + if (userId != null) { + // Step 2, request the avatar + requestAvatar(userId) + } } } @@ -241,11 +245,15 @@ class DefaultElementClassicConnection( ) } else { val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java) - val updatedState = currentState.copy( - avatar = avatar, - ) - coroutineScope.launch { - emitState(updatedState) + // If the avatar is identical to the current one, do not emit a new state to avoid unnecessary recompositions + // and blink on the avatar image + if (avatar == null || !avatar.sameAs(currentState.avatar)) { + val updatedState = currentState.copy( + avatar = avatar, + ) + coroutineScope.launch { + emitState(updatedState) + } } } } else { @@ -343,6 +351,10 @@ class DefaultElementClassicConnection( append(doesContainBackupKey) } ) + // Ensure avatar is not lost when refreshing the data + val currentAvatar = (stateFlow.value as? ElementClassicConnectionState.ElementClassicReady) + ?.takeIf { it.elementClassicSession.userId == userId } + ?.avatar ElementClassicConnectionState.ElementClassicReady( elementClassicSession = ElementClassicSession( userId = userId, @@ -352,7 +364,7 @@ class DefaultElementClassicConnection( doesContainBackupKey = doesContainBackupKey, ), displayName = displayName, - avatar = null, + avatar = currentAvatar, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt index 8d79453318..f2ff998652 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt @@ -11,6 +11,7 @@ import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -75,6 +76,11 @@ class ClassicFlowNode( override fun onBuilt() { super.onBuilt() observeElementClassicConnection() + lifecycle.subscribe( + onResume = { + classicFlowNodeHelper.onResume() + }, + ) } private fun observeElementClassicConnection() { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt index cae4f834d0..a5bc74c5e4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt @@ -26,6 +26,10 @@ class ClassicFlowNodeHelper( private val elementClassicConnection: ElementClassicConnection, private val sessionStore: SessionStore, ) { + fun onResume() { + elementClassicConnection.requestSession() + } + @OptIn(ExperimentalCoroutinesApi::class) fun navigationEventFlow(): Flow { return elementClassicConnection.stateFlow diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt index e3c6ed782d..6ba9b2142a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt @@ -8,7 +8,6 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic sealed interface LoginWithClassicEvent { - data object RefreshData : LoginWithClassicEvent data object Submit : LoginWithClassicEvent data object ClearError : LoginWithClassicEvent } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt index 6494ee741e..90a528c3ae 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt @@ -56,13 +56,6 @@ class LoginWithClassicPresenter( fun handleEvent(event: LoginWithClassicEvent) { when (event) { - LoginWithClassicEvent.RefreshData -> { - // Request the avatar if not known yet - val currentState = elementClassicConnection.stateFlow.value - if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) { - elementClassicConnection.requestAvatar(userId) - } - } LoginWithClassicEvent.Submit -> { val currentState = elementClassicConnection.stateFlow.value if (currentState is ElementClassicConnectionState.ElementClassicReady) { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt index aeb61946ff..6b5c48f1ec 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import io.element.android.compound.theme.ElementTheme import io.element.android.features.login.impl.R import io.element.android.features.login.impl.login.LoginModeView @@ -67,10 +65,6 @@ fun LoginWithClassicView( onCreateAccountContinue: (url: String) -> Unit, modifier: Modifier = Modifier, ) { - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - state.eventSink(LoginWithClassicEvent.RefreshData) - } - val isLoading by remember(state.loginMode) { derivedStateOf { state.loginMode is AsyncData.Loading diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt deleted file mode 100644 index a8b86ec1bf..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt +++ /dev/null @@ -1,12 +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.login.impl.screens.classic.missingkeybackup - -sealed interface MissingKeyBackupEvent { - data object OnResume : MissingKeyBackupEvent -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt index 7b8ea7e633..593c50dcb5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt @@ -8,38 +8,18 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta @Inject class MissingKeyBackupPresenter( private val buildMeta: BuildMeta, - private val elementClassicConnection: ElementClassicConnection, ) : Presenter { @Composable override fun present(): MissingKeyBackupState { - var resumeCounter by remember { mutableIntStateOf(0) } - fun handleEvent(event: MissingKeyBackupEvent) { - when (event) { - MissingKeyBackupEvent.OnResume -> { - resumeCounter++ - if (resumeCounter > 1) { - // The user has returned to this screen, we can assume they have gone to the backup flow and are now back here - elementClassicConnection.requestSession() - } - } - } - } - return MissingKeyBackupState( appName = buildMeta.applicationName, - eventSink = ::handleEvent, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt index 78d3d81c72..31eaf015a0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt @@ -9,5 +9,4 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup data class MissingKeyBackupState( val appName: String, - val eventSink: (MissingKeyBackupEvent) -> Unit ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt index 85d1042985..2c6a09b3ed 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt @@ -19,8 +19,6 @@ open class MissingKeyBackupStateProvider : PreviewParameterProvider Unit = {}, ) = MissingKeyBackupState( appName = appName, - eventSink = eventSink ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt index 67865ffcb8..c4c9c5f286 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.R import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism @@ -25,7 +24,6 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import kotlinx.collections.immutable.persistentListOf @Composable @@ -35,11 +33,6 @@ fun MissingKeyBackupView( onOpenClassicClick: () -> Unit, modifier: Modifier = Modifier, ) { - OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - state.eventSink.invoke(MissingKeyBackupEvent.OnResume) - } - } FlowStepPage( modifier = modifier, onBackClick = onBackClick, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt index 6b601543ce..227aa514b3 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt @@ -7,7 +7,6 @@ package io.element.android.features.login.impl.classic -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.tests.testutils.lambda.lambdaError import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,13 +16,11 @@ class FakeElementClassicConnection( private val startResult: () -> Unit = { lambdaError() }, private val stopResult: () -> Unit = { lambdaError() }, private val requestSessionResult: () -> Unit = { lambdaError() }, - private val requestAvatarResult: (UserId) -> Unit = { lambdaError() }, initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle ) : ElementClassicConnection { override fun start() = startResult() override fun stop() = stopResult() override fun requestSession() = requestSessionResult() - override fun requestAvatar(userId: UserId) = requestAvatarResult(userId) private val mutableStateFlow = MutableStateFlow(initialState) override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() suspend fun emitState(state: ElementClassicConnectionState) { 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 3bd2dd0cf8..6b2a4fb0e1 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 @@ -57,36 +57,6 @@ class LoginWithClassicPresenterTest { } } - @Test - fun `present - refresh data invokes the expected methods`() = runTest { - val requestAvatarResult = lambdaRecorder { } - val elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - requestAvatarResult = requestAvatarResult, - ) - val presenter = createPresenter( - elementClassicConnection = elementClassicConnection, - ) - presenter.test { - skipItems(1) - elementClassicConnection.emitState( - anElementClassicReady( - elementClassicSession = anElementClassicSession( - userId = A_USER_ID, - secrets = A_SECRET, - roomKeysVersion = ROOM_KEYS_VERSION, - ), - displayName = A_USER_NAME, - ) - ) - val readyState = awaitItem() - assertThat(readyState.userId).isEqualTo(A_USER_ID) - assertThat(readyState.displayName).isEqualTo(A_USER_NAME) - readyState.eventSink(LoginWithClassicEvent.RefreshData) - requestAvatarResult.assertions().isCalledOnce() - } - } - @Test fun `present - start login with correct state - user can login`() = runTest { val authenticationService = FakeMatrixAuthenticationService( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt index 2a4af8bf5a..447b0ba77b 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt @@ -8,12 +8,9 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.impl.classic.ElementClassicConnection -import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME import io.element.android.libraries.matrix.test.core.aBuildMeta -import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,29 +24,10 @@ class MissingKeyBackupPresenterTest { assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) } } - - @Test - fun `present - when the screen is resumed twice, the start over method is called`() = runTest { - val requestSessionResult = lambdaRecorder { } - val presenter = createPresenter( - elementClassicConnection = FakeElementClassicConnection( - requestSessionResult = requestSessionResult, - ), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(MissingKeyBackupEvent.OnResume) - expectNoEvents() - initialState.eventSink(MissingKeyBackupEvent.OnResume) - requestSessionResult.assertions().isCalledOnce() - } - } } private fun createPresenter( buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), - elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), ) = MissingKeyBackupPresenter( buildMeta = buildMeta, - elementClassicConnection = elementClassicConnection, ) From 4cc9c772646beb8f7d20624da0f552594e996b5a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Apr 2026 08:32:26 +0200 Subject: [PATCH 23/53] Use @TestParameter instead of doing the work manually. --- .../screens/onboarding/OnboardingViewTest.kt | 42 ++++++------------- .../kotlin/extension/DependencyHandleScope.kt | 1 + 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt index c8dcd978c6..c3d1ff6015 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -13,7 +13,8 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues +import com.google.testing.junit.testparameterinjector.TestParameter import io.element.android.features.login.impl.R import io.element.android.features.login.impl.login.LoginMode import io.element.android.libraries.architecture.AsyncData @@ -31,8 +32,9 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.junit.runner.RunWith +import org.robolectric.RobolectricTestParameterInjector -@RunWith(AndroidJUnit4::class) +@RunWith(RobolectricTestParameterInjector::class) class OnboardingViewTest { @get:Rule val rule = createAndroidComposeRule() @@ -83,21 +85,11 @@ class OnboardingViewTest { } @Test - fun `when can login with QR code - clicking on sign in manually calls the expected callback - can search account provider`() { - `when can login with QR code - clicking on sign in manually calls the expected callback`( - mustChooseAccountProvider = false, + fun `when can login with QR code - clicking on sign in manually calls the expected callback`( + @TestParameter mustChooseAccountProvider: Boolean = namedTestValues( + "can search account provider" to false, + "cannot search account provider" to true, ) - } - - @Test - fun `when can login with QR code - clicking on sign in manually calls the expected callback - cannot search account provider`() { - `when can login with QR code - clicking on sign in manually calls the expected callback`( - mustChooseAccountProvider = true, - ) - } - - private fun `when can login with QR code - clicking on sign in manually calls the expected callback`( - mustChooseAccountProvider: Boolean, ) { val eventSink = EventsRecorder(expectEvents = false) ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> @@ -114,21 +106,11 @@ class OnboardingViewTest { } @Test - fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - can search account provider`() { - `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( - mustChooseAccountProvider = false, + fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( + @TestParameter mustChooseAccountProvider: Boolean = namedTestValues( + "can search account provider" to false, + "cannot search account provider" to true, ) - } - - @Test - fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - cannot search account provider`() { - `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( - mustChooseAccountProvider = true, - ) - } - - private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( - mustChooseAccountProvider: Boolean, ) { val eventSink = EventsRecorder(expectEvents = false) ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 8a5dfa2c4a..ce5c324ff4 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -49,6 +49,7 @@ fun DependencyHandlerScope.testCommonDependencies( testImplementation(libs.test.arch.core) testImplementation(libs.test.junit) testImplementation(libs.test.mockk) + testImplementation(libs.test.parameter.injector) testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) From 937973570a5252b8824f2654ab4569a45536a9b6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Apr 2026 08:39:14 +0200 Subject: [PATCH 24/53] Use @TestParameter instead of doing the work manually. --- .../space/impl/root/SpacePresenterTest.kt | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index e183de6f27..ba4e10a447 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -11,6 +11,9 @@ package io.element.android.features.space.impl.root import com.google.common.truth.Truth.assertThat +import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState @@ -49,8 +52,10 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test +import org.junit.runner.RunWith import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom +@RunWith(TestParameterInjector::class) class SpacePresenterTest { @Test fun `present - initial state`() = runTest { @@ -259,21 +264,11 @@ class SpacePresenterTest { } @Test - fun `present - accept invite is transmitted to acceptDeclineInviteState`() { - `invite action is transmitted to acceptDeclineInviteState`( - acceptInvite = true, - ) - } - - @Test - fun `present - decline invite is transmitted to acceptDeclineInviteState`() { - `invite action is transmitted to acceptDeclineInviteState`( - acceptInvite = false, - ) - } - - private fun `invite action is transmitted to acceptDeclineInviteState`( - acceptInvite: Boolean, + fun `present - invite action is transmitted to acceptDeclineInviteState`( + @TestParameter acceptInvite: Boolean = namedTestValues( + "accept" to true, + "decline" to false, + ), ) = runTest { val eventRecorder = EventsRecorder() val anInvitedRoom = aSpaceRoom( From e0554bbaf38776d07b557f7b316eb064608bc93f Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 15 Apr 2026 09:58:03 +0200 Subject: [PATCH 25/53] Use `Coil3` for `ZoomableAsyncImage` (#6582) The `-coil` version uses Coil 2.X, there is a `-coil3` version using the latest one, which matches the one we use in the rest of the app. --- gradle/libs.versions.toml | 2 +- .../libraries/mediaviewer/impl/local/image/MediaImageView.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2288b09220..895bad253f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -204,7 +204,7 @@ sqlcipher = "net.zetetic:sqlcipher-android:4.14.1" sqlite = "androidx.sqlite:sqlite-ktx:2.6.2" unifiedpush = "org.unifiedpush.android:connector:3.3.2" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" -telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } +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.0.2" diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt index 306a18b4d6..4c73dd215d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState import io.element.android.libraries.ui.strings.CommonStrings -import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState @Composable From 897c68e7b7c6186f67b512fd38f7729cd6106f06 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 15 Apr 2026 10:25:58 +0100 Subject: [PATCH 26/53] Add confirmation dialog when inviting users with unknown identities (#6523) * feat: Add confirmation modal when inviting unknown users * tests: Add preview tests for invite confirmation modal * tests: Add unit tests for invite confirmation modal * feat: Switch confirmation sheet contents based on identity state * tests: Add history sharing unit tests for `DefaultStartDMActionTest` * tests: Update snapshots for `CreateDmConfirmationBottomSheet` * chore: Fix tiny nits * fix: Remove default param on `ConfirmingStartDmWithMatrixUser` * refactor: Use new AsyncAction over boolean flag * fix: Add sleeps to tests * refactor: Remove `PromptOrInvite` and switch on async action * fix: Remove redundant `assertThat` * feat: Alllow invite confirmation modal to be dismissed * tests: Update snapshots for InvitePeopleView * fix: Adjust `CreateDmConfirmationBottomSheet` to conform to design * feat: Use localazy translations and plurals * fix: When users are unselected, unselect them in search results too * tests: Use aMatrixUserList to provide multiple users * Update screenshots * fix: Add missing parameter in UserProfilePresenterTest --------- Co-authored-by: Andy Balaam Co-authored-by: ElementBot --- features/invitepeople/impl/build.gradle.kts | 2 + .../impl/ConfirmingUnknownUserInvitation.kt | 16 ++ .../impl/DefaultInvitePeopleEvents.kt | 2 + .../impl/DefaultInvitePeoplePresenter.kt | 86 ++++++- .../impl/DefaultInvitePeopleStateProvider.kt | 10 + .../invitepeople/impl/InvitePeopleView.kt | 71 ++++++ .../impl/DefaultInvitePeoplePresenterTest.kt | 234 ++++++++++++++++++ .../api/ConfirmingStartDmWithMatrixUser.kt | 1 + .../startchat/impl/DefaultStartDMAction.kt | 9 +- .../startchat/impl/root/StartChatPresenter.kt | 3 + .../startchat/impl/root/StartChatState.kt | 1 + .../impl/root/StartChatStateProvider.kt | 14 +- .../startchat/impl/root/StartChatView.kt | 2 + .../impl/DefaultStartDMActionTest.kt | 41 ++- .../impl/root/StartChatPresenterTest.kt | 4 +- .../userprofile/api/UserProfileState.kt | 1 + features/userprofile/impl/build.gradle.kts | 2 + .../impl/root/UserProfilePresenter.kt | 7 + .../impl/UserProfilePresenterTest.kt | 6 +- .../shared/UserProfileStateProvider.kt | 3 +- .../userprofile/shared/UserProfileView.kt | 2 + .../CreateDmConfirmationBottomSheet.kt | 133 +++++++--- ...people.impl_InvitePeopleView_Day_10_en.png | 3 + ...people.impl_InvitePeopleView_Day_11_en.png | 3 + ...ople.impl_InvitePeopleView_Night_10_en.png | 3 + ...ople.impl_InvitePeopleView_Night_11_en.png | 3 + ...eateDmConfirmationBottomSheet_Day_1_en.png | 4 +- ...eateDmConfirmationBottomSheet_Day_2_en.png | 3 + ...teDmConfirmationBottomSheet_Night_1_en.png | 4 +- ...teDmConfirmationBottomSheet_Night_2_en.png | 3 + 30 files changed, 618 insertions(+), 58 deletions(-) create mode 100644 features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts index 0c3f988659..390ccce7b9 100644 --- a/features/invitepeople/impl/build.gradle.kts +++ b/features/invitepeople/impl/build.gradle.kts @@ -37,10 +37,12 @@ dependencies { implementation(projects.libraries.usersearch.api) implementation(libs.coil.compose) implementation(projects.services.apperror.api) + implementation(projects.libraries.featureflag.api) api(projects.features.invitepeople.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.services.apperror.test) + testImplementation(projects.libraries.featureflag.test) } diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt new file mode 100644 index 0000000000..8f4d5e1510 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt @@ -0,0 +1,16 @@ +/* + * 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.invitepeople.impl + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class ConfirmingUnknownUserInvitation( + val users: ImmutableList +) : AsyncAction.Confirming diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt index b1f18b1df9..449d0ce6ac 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt @@ -14,4 +14,6 @@ import io.element.android.libraries.matrix.api.user.MatrixUser sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents { data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents + data object DismissUnknownUsersModal : DefaultInvitePeopleEvents + data object RemoveUnknownUsers : DefaultInvitePeopleEvents } diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt index 3450587e82..58b3fb67f6 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,8 +37,11 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope +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.core.RoomId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState @@ -50,6 +54,7 @@ import io.element.android.services.apperror.api.AppErrorStateService import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.launchIn @@ -69,6 +74,7 @@ class DefaultInvitePeoplePresenter( private val coroutineDispatchers: CoroutineDispatchers, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val appErrorStateService: AppErrorStateService, + private val featureFlagService: FeatureFlagService, private val matrixClient: MatrixClient, ) : InvitePeoplePresenter { @AssistedFactory @@ -87,6 +93,8 @@ class DefaultInvitePeoplePresenter( val showSearchLoader = rememberSaveable { mutableStateOf(false) } val sendInvitesAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + val enableKeyShareOnInvite by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false) + val recentDirectRooms by produceState(emptyList(), roomMembers.value) { if (roomMembers.value.isSuccess()) { val activeMemberIds = roomMembers.value.dataOrNull().orEmpty() @@ -126,6 +134,40 @@ class DefaultInvitePeoplePresenter( } } + val selectedUserIdentities = produceState( + emptyMap().toImmutableMap(), + selectedUsers.value, + enableKeyShareOnInvite, + ) { + if (!enableKeyShareOnInvite) { + return@produceState + } + + val selected = selectedUsers.value + + val cached = value + .filterKeys { it in selected } + + val uncached = selected + .filterNot(cached::containsKey) + .associateWith { user -> + matrixClient.encryptionService + .getUserIdentity(user.userId, fallbackToServer = false) + .getOrNull() + } + + value = (cached + uncached).toImmutableMap() + } + + val unknownUsers by remember { + derivedStateOf { + selectedUserIdentities.value + .filterValues { it == null } + .keys + .toImmutableList() + } + } + LaunchedEffect(room.isSuccess()) { room.dataOrNull()?.let { fetchMembers(it, roomMembers) @@ -144,21 +186,41 @@ class DefaultInvitePeoplePresenter( fun handleEvent(event: InvitePeopleEvents) { when (event) { - is DefaultInvitePeopleEvents.OnSearchActiveChanged -> { - searchActive = event.active - if (!event.active) { - queryState.clearText() + // Dedicated `when` for exhaustivity. + is DefaultInvitePeopleEvents -> when (event) { + is DefaultInvitePeopleEvents.OnSearchActiveChanged -> { + searchActive = event.active + if (!event.active) { + queryState.clearText() + } + } + + is DefaultInvitePeopleEvents.ToggleUser -> { + selectedUsers.toggleUser(event.user) + searchResults.toggleUser(event.user) + // suggestions will automatically update via derivedStateOf when selectedUsers changes + } + is DefaultInvitePeopleEvents.DismissUnknownUsersModal -> { + sendInvitesAction.value = AsyncAction.Uninitialized + } + is DefaultInvitePeopleEvents.RemoveUnknownUsers -> { + val usersToRemove = selectedUsers.value.filter { it in unknownUsers } + usersToRemove.forEach { user -> + selectedUsers.toggleUser(user) + searchResults.toggleUser(user) + } + sendInvitesAction.value = AsyncAction.Uninitialized } } - - is DefaultInvitePeopleEvents.ToggleUser -> { - selectedUsers.toggleUser(event.user) - searchResults.toggleUser(event.user) - // suggestions will automatically update via derivedStateOf when selectedUsers changes - } is InvitePeopleEvents.SendInvites -> { - room.dataOrNull()?.let { - sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction) + if (enableKeyShareOnInvite && unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) { + sendInvitesAction.value = ConfirmingUnknownUserInvitation( + unknownUsers + ) + } else { + room.dataOrNull()?.let { + sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction) + } } } is InvitePeopleEvents.CloseSearch -> { diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt index 15ded2ae3f..c26b8de254 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt @@ -76,6 +76,16 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider, + onDismiss: () -> Unit, + onInvite: () -> Unit, + onRemove: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = null, + ) { + IconTitleSubtitleMolecule( + title = pluralStringResource(R.plurals.screen_invite_users_confirm_dialog_title, users.size), + subTitle = pluralStringResource(R.plurals.screen_invite_users_confirm_dialog_subtitle, users.size), + iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()), + modifier = Modifier.padding( + top = 32.dp, + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ) + ) + + LazyColumn { + items(users) { user -> + MatrixUserRow(user) + } + } + + ButtonRowMolecule( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + OutlinedButton( + text = stringResource(CommonStrings.action_remove), + onClick = onRemove, + leadingIcon = IconSource.Vector(CompoundIcons.Close()), + modifier = Modifier.weight(1f) + ) + Button( + text = stringResource(CommonStrings.action_invite), + onClick = onInvite, + leadingIcon = IconSource.Vector(CompoundIcons.Check()), + modifier = Modifier.weight(1f) + ) + } + } +} + @PreviewsDayNight @Composable internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) = diff --git a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt index ab9e20437e..a1d72010f6 100644 --- a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt +++ b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt @@ -15,9 +15,13 @@ import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +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.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMembersState @@ -28,6 +32,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_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.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.aRoomInfo @@ -43,6 +48,7 @@ import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.services.apperror.api.AppErrorStateService import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -56,6 +62,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +@Suppress("LargeClass") internal class DefaultInvitePeoplePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -605,6 +612,231 @@ internal class DefaultInvitePeoplePresenterTest { } } + @Test + fun `present - users are prompted for confirmation if they attempt to invite unknown users`() = runTest { + val alice = aMatrixUser("@alice:example.com") + val bob = aMatrixUser("@bob:example.com") + val charlie = aMatrixUser("@charlie:example.com") + + val getUserIdentityResult = lambdaRecorder> { userId -> + when (userId.value) { + alice.userId.value -> Result.success(IdentityState.Pinned) + bob.userId.value -> Result.success(null) + else -> Result.failure(AN_EXCEPTION) + } + } + + val inviteUserResult = lambdaRecorder> { userId: UserId -> + Result.success(Unit) + } + val encryptionService = FakeEncryptionService( + getUserIdentityResult = getUserIdentityResult + ) + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true) + } + + val presenter = createDefaultInvitePeoplePresenter( + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + inviteUserResult = inviteUserResult, + matrixClient = FakeMatrixClient(encryptionService = encryptionService), + featureFlagService = featureFlagService + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + // When we toggle a user not in the list, they are added, and we fetch their identity. + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) + delay(100) + awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob)) + delay(100) + awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie)) + delay(100) + + // If we do not have their identity cached, or fail to fetch it, we should mark them as unknown. + awaitItemAsDefault().run { + assertThat(selectedUsers).containsExactly(alice, bob, charlie) + eventSink(InvitePeopleEvents.SendInvites) + } + + getUserIdentityResult.assertions().isCalledExactly(3).withSequence( + listOf(value(alice.userId)), + listOf(value(bob.userId)), + listOf(value(charlie.userId)) + ) + + // When we then try to invite these users, we should prompt for confirmation first. + awaitItemAsDefault().run { + assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java) + assertThat(canInvite).isTrue() + eventSink(InvitePeopleEvents.SendInvites) + } + + delay(1_000) + inviteUserResult.assertions().isCalledExactly(3).withSequence( + listOf(value(alice.userId)), + listOf(value(bob.userId)), + listOf(value(charlie.userId)) + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - selecting remove on confirmation prompt unselects unknown users`() = runTest { + val alice = aMatrixUser("@alice:example.com") + val bob = aMatrixUser("@bob:example.com") + val charlie = aMatrixUser("@charlie:example.com") + + val repository = FakeUserRepository() + + val getUserIdentityResult = lambdaRecorder> { userId -> + when (userId.value) { + alice.userId.value -> Result.success(IdentityState.Pinned) + bob.userId.value -> Result.success(null) + else -> Result.failure(AN_EXCEPTION) + } + } + + val encryptionService = FakeEncryptionService( + getUserIdentityResult = getUserIdentityResult + ) + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true) + } + + val presenter = createDefaultInvitePeoplePresenter( + userRepository = repository, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + matrixClient = FakeMatrixClient(encryptionService = encryptionService), + featureFlagService = featureFlagService + ) + presenter.test { + val initialState = awaitItemAsDefault() + skipItems(1) + + // When we toggle a user not in the list, they are added, and we fetch their identity. + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) + delay(100) + awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob)) + delay(100) + awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie)) + delay(100) + + // And the search is matching Alice and Bob + initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query") + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitState( + UserSearchResultState( + results = listOf(UserSearchResult(alice), UserSearchResult(bob)), + isSearching = true + ) + ) + skipItems(3) + + awaitItemAsDefault().run { + assertThat(selectedUsers).containsExactly(alice, bob, charlie) + + // Both Alice and Bob are selected in searchResults + assertThat( + searchResults.users().map { Pair(it.matrixUser, it.isSelected) } + ).containsExactly(Pair(alice, true), Pair(bob, true)) + + eventSink(InvitePeopleEvents.SendInvites) + } + + getUserIdentityResult.assertions().isCalledExactly(3).withSequence( + listOf(value(alice.userId)), + listOf(value(bob.userId)), + listOf(value(charlie.userId)) + ) + + // When we then try to invite these user, we should prompt for confirmation first. + awaitItemAsDefault().run { + assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java) + assertThat(canInvite).isTrue() + eventSink(DefaultInvitePeopleEvents.RemoveUnknownUsers) + } + + // Selecting "remove" should remove all unknown users, but keeps those who are known. + (awaitLastSequentialItem() as DefaultInvitePeopleState).run { + assertThat(sendInvitesAction.isUninitialized()).isTrue() + assertThat(selectedUsers).containsExactly(alice) + + // Bob is no longer selected in searchResults + assertThat( + searchResults.users().map { Pair(it.matrixUser, it.isSelected) } + ).containsExactly(Pair(alice, true), Pair(bob, false)) + } + } + } + + @Test + fun `present - dismissing confirmation prompt does not affect selection`() = runTest { + val alice = aMatrixUser("@alice:example.com") + val bob = aMatrixUser("@bob:example.com") + val charlie = aMatrixUser("@charlie:example.com") + + val getUserIdentityResult = lambdaRecorder> { userId -> + when (userId.value) { + alice.userId.value -> Result.success(IdentityState.Pinned) + bob.userId.value -> Result.success(null) + else -> Result.failure(AN_EXCEPTION) + } + } + + val encryptionService = FakeEncryptionService( + getUserIdentityResult = getUserIdentityResult + ) + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true) + } + + val presenter = createDefaultInvitePeoplePresenter( + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + matrixClient = FakeMatrixClient(encryptionService = encryptionService), + featureFlagService = featureFlagService + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + // When we toggle a user not in the list, they are added, and we fetch their identity. + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) + delay(100) + awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob)) + delay(100) + awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie)) + delay(100) + + awaitItemAsDefault().run { + assertThat(selectedUsers).containsExactly(alice, bob, charlie) + eventSink(InvitePeopleEvents.SendInvites) + } + + getUserIdentityResult.assertions().isCalledExactly(3).withSequence( + listOf(value(alice.userId)), + listOf(value(bob.userId)), + listOf(value(charlie.userId)) + ) + + // When we then try to invite these user, we should prompt for confirmation first. + awaitItemAsDefault().run { + assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java) + assertThat(canInvite).isTrue() + eventSink(DefaultInvitePeopleEvents.DismissUnknownUsersModal) + } + + // Dismissing should not modify the selection at all + (awaitLastSequentialItem() as DefaultInvitePeopleState).run { + assertThat(sendInvitesAction.isUninitialized()).isTrue() + assertThat(selectedUsers).containsExactly(alice, bob, charlie) + } + } + } + private suspend fun FakeUserRepository.emitStateWithUsers( users: List, isSearching: Boolean = false @@ -646,6 +878,7 @@ fun TestScope.createDefaultInvitePeoplePresenter( userRepository: UserRepository = FakeUserRepository(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), appErrorStateService: AppErrorStateService = FakeAppErrorStateService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), matrixClient: MatrixClient = FakeMatrixClient(), ): DefaultInvitePeoplePresenter { return DefaultInvitePeoplePresenter( @@ -655,6 +888,7 @@ fun TestScope.createDefaultInvitePeoplePresenter( coroutineDispatchers = coroutineDispatchers, sessionCoroutineScope = backgroundScope, appErrorStateService = appErrorStateService, + featureFlagService = featureFlagService, matrixClient = matrixClient, ) } diff --git a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt index 5bf015c0f0..059002d983 100644 --- a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt +++ b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt @@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser data class ConfirmingStartDmWithMatrixUser( val matrixUser: MatrixUser, + val isUserIdentityUnknown: Boolean, ) : AsyncAction.Confirming diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt index a484fe2e72..3bfbd1ca18 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt @@ -15,6 +15,8 @@ import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.di.SessionScope +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.core.RoomId import io.element.android.libraries.matrix.api.room.StartDMResult @@ -26,6 +28,7 @@ import io.element.android.services.analytics.api.AnalyticsService class DefaultStartDMAction( private val matrixClient: MatrixClient, private val analyticsService: AnalyticsService, + private val featureFlagService: FeatureFlagService, ) : StartDMAction { override suspend fun execute( matrixUser: MatrixUser, @@ -44,7 +47,11 @@ class DefaultStartDMAction( actionState.value = AsyncAction.Failure(result.throwable) } StartDMResult.DmDoesNotExist -> { - actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser) + val identityState = matrixClient.encryptionService.getUserIdentity(matrixUser.userId, fallbackToServer = false).getOrNull() + actionState.value = ConfirmingStartDmWithMatrixUser( + matrixUser = matrixUser, + isUserIdentityUnknown = featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite) && identityState == null + ) } } } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt index e176f202ad..7afbe19c3d 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt @@ -58,6 +58,8 @@ class StartChatPresenter( featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch) }.collectAsState(initial = false) + val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false) + fun handleEvent(event: StartChatEvents) { when (event) { is StartChatEvents.StartDM -> localCoroutineScope.launch { @@ -76,6 +78,7 @@ class StartChatPresenter( userListState = userListState, startDmAction = startDmActionState.value, isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled, + enableKeyShareOnInvite = enableKeyShareOnInvite.value, eventSink = ::handleEvent, ) } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt index 65f977d3e3..989a5b8d20 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt @@ -17,5 +17,6 @@ data class StartChatState( val userListState: UserListState, val startDmAction: AsyncAction, val isRoomDirectorySearchEnabled: Boolean, + val enableKeyShareOnInvite: Boolean, val eventSink: (StartChatEvents) -> Unit, ) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt index 448ad1a80a..17d83a9e11 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt @@ -16,6 +16,7 @@ import io.element.android.features.startchat.impl.userlist.aUserListState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.usersearch.api.UserSearchResult import kotlinx.collections.immutable.persistentListOf @@ -52,7 +53,7 @@ open class StartChatStateProvider : PreviewParameterProvider { ) ), aCreateRoomRootState( - startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()), + startDmAction = aConfirmingStartDmWithMatrixUser() ), aCreateRoomRootState( isRoomDirectorySearchEnabled = true, @@ -60,6 +61,16 @@ open class StartChatStateProvider : PreviewParameterProvider { ) } +fun aConfirmingStartDmWithMatrixUser( + matrixUser: MatrixUser = aMatrixUser(), + isUserIdentityUnknown: Boolean = false +): ConfirmingStartDmWithMatrixUser { + return ConfirmingStartDmWithMatrixUser( + matrixUser, + isUserIdentityUnknown + ) +} + fun aCreateRoomRootState( applicationName: String = "Element X Preview", userListState: UserListState = aUserListState(), @@ -71,5 +82,6 @@ fun aCreateRoomRootState( userListState = userListState, startDmAction = startDmAction, isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled, + enableKeyShareOnInvite = false, eventSink = eventSink, ) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt index 0b8da1bd94..28bf52549e 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt @@ -130,6 +130,8 @@ fun StartChatView( if (data is ConfirmingStartDmWithMatrixUser) { CreateDmConfirmationBottomSheet( matrixUser = data.matrixUser, + enableKeyShareOnInvite = state.enableKeyShareOnInvite, + isUserIdentityUnknown = data.isUserIdentityUnknown, onSendInvite = { state.eventSink(StartChatEvents.StartDM(data.matrixUser)) }, diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt index 122775f2cc..88b935e47d 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt @@ -13,14 +13,21 @@ import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.CreatedRoom import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.libraries.architecture.AsyncAction +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.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.test.runTest import org.junit.Test @@ -67,7 +74,12 @@ class DefaultStartDMActionTest { @Test fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest { - val matrixClient = FakeMatrixClient().apply { + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(null) } + ) + val matrixClient = FakeMatrixClient( + encryptionService = encryptionService + ).apply { givenFindDmResult(Result.success(null)) givenCreateDmResult(Result.success(A_ROOM_ID)) } @@ -76,7 +88,7 @@ class DefaultStartDMActionTest { val state = mutableStateOf>(AsyncAction.Uninitialized) val matrixUser = aMatrixUser() action.execute(matrixUser, false, state) - assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser)) + assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false)) assertThat(analyticsService.capturedEvents).isEmpty() } @@ -94,13 +106,38 @@ class DefaultStartDMActionTest { assertThat(analyticsService.capturedEvents).isEmpty() } + @Test + fun `when history sharing enabled, user identity fetched and identity unknown`() = runTest { + val getUserIdentityResult = lambdaRecorder> { _ -> Result.success(null) } + val encryptionService = FakeEncryptionService(getUserIdentityResult = getUserIdentityResult) + val matrixClient = FakeMatrixClient(encryptionService = encryptionService).apply { + givenFindDmResult(Result.success(null)) + } + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true) + } + + val action = createStartDMAction( + matrixClient = matrixClient, + featureFlagService = featureFlagService + ) + val state = mutableStateOf>(AsyncAction.Uninitialized) + + action.execute(aMatrixUser(), false, state) + + assertThat(getUserIdentityResult.assertions().isCalledOnce()) + assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(aMatrixUser(), isUserIdentityUnknown = true)) + } + private fun createStartDMAction( matrixClient: MatrixClient = FakeMatrixClient(), analyticsService: AnalyticsService = FakeAnalyticsService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService() ): DefaultStartDMAction { return DefaultStartDMAction( matrixClient = matrixClient, analyticsService = analyticsService, + featureFlagService = featureFlagService, ) } } diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt index 7c209d9052..2bc15e989d 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt @@ -102,7 +102,7 @@ class StartChatPresenterTest { @Test fun `present - start DM action confirmation scenario - cancel`() = runTest { val matrixUser = MatrixUser(UserId("@name:domain")) - val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false) val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> actionState.value = startDMConfirmationResult } @@ -130,7 +130,7 @@ class StartChatPresenterTest { @Test fun `present - start DM action confirmation scenario - confirm`() = runTest { val matrixUser = MatrixUser(UserId("@name:domain")) - val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false) val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> actionState.value = startDMConfirmationResult } diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt index 0e0016ee14..e2a309c17f 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt @@ -26,6 +26,7 @@ data class UserProfileState( val dmRoomId: RoomId?, val canCall: Boolean, val snackbarMessage: SnackbarMessage?, + val enableKeyShareOnInvite: Boolean, val eventSink: (UserProfileEvents) -> Unit ) { enum class ConfirmationDialog { diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts index 0b65441cc3..3e68fb2b9c 100644 --- a/features/userprofile/impl/build.gradle.kts +++ b/features/userprofile/impl/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.featureflag.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.verifysession.api) @@ -46,6 +47,7 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.features.startchat.test) diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index 7e09a03ec3..a451d86b70 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -31,6 +32,8 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.bool.orFalse +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.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -50,6 +53,7 @@ class UserProfilePresenter( private val client: MatrixClient, private val startDMAction: StartDMAction, private val sessionEnterpriseService: SessionEnterpriseService, + private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory interface Factory { @@ -101,6 +105,8 @@ class UserProfilePresenter( } val userProfile by produceState(null) { value = client.getProfile(userId).getOrNull() } + val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false) + fun handleEvent(event: UserProfileEvents) { when (event) { is UserProfileEvents.BlockUser -> { @@ -153,6 +159,7 @@ class UserProfilePresenter( dmRoomId = dmRoomId, canCall = canCall, snackbarMessage = null, + enableKeyShareOnInvite = enableKeyShareOnInvite.value, eventSink = ::handleEvent, ) } diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 511effe750..1325b46bc0 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -24,6 +24,7 @@ import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.impl.root.UserProfilePresenter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -324,7 +325,7 @@ class UserProfilePresenterTest { @Test fun `present - start DM action confirmation scenario - cancel`() = runTest { val matrixUser = MatrixUser(UserId("@alice:server.org")) - val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, false) val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> actionState.value = startDMConfirmationResult } @@ -354,7 +355,7 @@ class UserProfilePresenterTest { @Test fun `present - start DM action confirmation scenario - confirm`() = runTest { val matrixUser = MatrixUser(UserId("@alice:server.org")) - val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, false) val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> actionState.value = startDMConfirmationResult } @@ -414,6 +415,7 @@ class UserProfilePresenterTest { sessionEnterpriseService = FakeSessionEnterpriseService( isElementCallAvailableResult = { isElementCallAvailable }, ), + featureFlagService = FakeFeatureFlagService() ) } } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt index 49a2fee4b5..a4bbcd6aa4 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt @@ -31,7 +31,7 @@ open class UserProfileStateProvider : PreviewParameterProvider aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN), aUserProfileState(startDmActionState = AsyncAction.Loading), aUserProfileState(canCall = true), - aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())), + aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser(), isUserIdentityUnknown = false)), aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION), ) } @@ -61,5 +61,6 @@ fun aUserProfileState( dmRoomId = dmRoomId, canCall = canCall, snackbarMessage = snackbarMessage, + enableKeyShareOnInvite = false, eventSink = eventSink, ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 380bb006ab..34f992f77d 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -114,6 +114,8 @@ fun UserProfileView( if (data is ConfirmingStartDmWithMatrixUser) { CreateDmConfirmationBottomSheet( matrixUser = data.matrixUser, + enableKeyShareOnInvite = state.enableKeyShareOnInvite, + isUserIdentityUnknown = data.isUserIdentityUnknown, onSendInvite = { state.eventSink(UserProfileEvents.StartDM) }, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt index dca173d780..7e0e51050a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.ui.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -22,9 +23,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule +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.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType @@ -33,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.R @@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun CreateDmConfirmationBottomSheet( matrixUser: MatrixUser, + enableKeyShareOnInvite: Boolean, + isUserIdentityUnknown: Boolean, onSendInvite: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { + val titleContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) { + stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_title) + } else { + stringResource(R.string.screen_bottom_sheet_create_dm_title) + } + val descriptionContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) { + stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_content) + } else { + stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()) + } + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismiss, @@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet( .padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Avatar( - avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation), - avatarType = AvatarType.User, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.screen_bottom_sheet_create_dm_title), - style = ElementTheme.typography.fontHeadingMdBold, - color = ElementTheme.colors.textPrimary, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()), - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(40.dp)) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onSendInvite, - leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()), - text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title), - ) - Spacer(modifier = Modifier.height(16.dp)) - TextButton( - modifier = Modifier.fillMaxWidth(), - onClick = onDismiss, - text = stringResource(CommonStrings.action_cancel), - ) + if (isUserIdentityUnknown) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ), + title = titleContent, + subTitle = descriptionContent, + iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()), + ) + MatrixUserRow(matrixUser) + Spacer(modifier = Modifier.height(32.dp)) + ButtonRowMolecule( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + text = stringResource(CommonStrings.action_cancel), + onClick = onDismiss + ) + Button( + modifier = Modifier.weight(1f), + text = stringResource(CommonStrings.action_continue), + onClick = onSendInvite + ) + } + Spacer(modifier = Modifier.height(32.dp)) + } else { + Avatar( + avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = titleContent, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = descriptionContent, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(40.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onSendInvite, + leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()), + text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title), + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onDismiss, + text = stringResource(CommonStrings.action_cancel), + ) + } } } } @PreviewsDayNight @Composable -internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { +internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter( + CreateDmConfirmationBottomSheetStateProvider::class +) state: CreateDmConfirmationBottomSheetState) = ElementPreview { CreateDmConfirmationBottomSheet( - matrixUser = matrixUser, + matrixUser = state.matrixUser, + enableKeyShareOnInvite = state.enableKeyShareOnInvite, + isUserIdentityUnknown = state.isUserIdentityUnknown, onSendInvite = {}, onDismiss = {}, ) } + +data class CreateDmConfirmationBottomSheetState( + val matrixUser: MatrixUser, + val enableKeyShareOnInvite: Boolean, + val isUserIdentityUnknown: Boolean, +) + +class CreateDmConfirmationBottomSheetStateProvider : PreviewParameterProvider { + override val values = sequenceOf( + CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = false, isUserIdentityUnknown = false), + CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = false), + CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = true), + ) +} diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png new file mode 100644 index 0000000000..8c6b1bd7cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8ee76c2369a9671cbe370f367718fcda5bb08a89ed5116accc96928a64e9724 +size 55689 diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png new file mode 100644 index 0000000000..35f633b3a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:029b19808dd54b74ef30737242a10af31b80e579b313d001dd9fa377bc2cca58 +size 58319 diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png new file mode 100644 index 0000000000..7983ef5e8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21a24fade9819efdb9114ec0ba3db21ec87cf93e32d896e22117fcd4f23e07ce +size 53601 diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png new file mode 100644 index 0000000000..1c50d23117 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eca54577cffddb66921623ede7ab39e017f5cd95e5049d6ad2763fa4f1f88ad4 +size 58133 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png index acec20812f..c7e3599c58 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c5a4487507334ec43c9d659f57f2ec0d86856d941f8b1b437c101b696a5b49d -size 24223 +oid sha256:bb4d6bfb9c412de00a2b4956032dd42906b5451eb99e6ebb1880dc01f6b55af5 +size 26077 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png new file mode 100644 index 0000000000..d7ff4a1d2f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26bf76ccdb56d042422553f557d91d0f26d874a710f696ac106c5c2b5590d332 +size 38833 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png index 0c60a3da07..fe44b8941c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab0ba9a693ede4106d09170710f215bccfd82dbfbadfdafa5fb49fe39a03c25d -size 23471 +oid sha256:b8c422787b67d477d3b7c8d5dee8879f33d47153dc93dd29bb3883e4ed863a41 +size 25232 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png new file mode 100644 index 0000000000..f5ff7856b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8413aed02383572cfe8c481c6ba8b0db4cfb3402334c37f2d8b54a73fe4bf594 +size 37343 From e4bee737b1ea715441ed9c7a80fe4f5363ce404a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Apr 2026 12:00:29 +0200 Subject: [PATCH 27/53] Take into account the value of FeatureFlags.SignInWithClassic --- .../impl/classic/ElementClassicConnection.kt | 12 +++++++++ .../DefaultElementClassicConnectionTest.kt | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index dfddd1d496..c928c05239 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -28,6 +28,8 @@ import io.element.android.libraries.androidutils.service.ServiceBinder import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.auth.ElementClassicSession import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -69,6 +71,7 @@ class DefaultElementClassicConnection( private val coroutineScope: CoroutineScope, private val matrixAuthenticationService: MatrixAuthenticationService, private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, + private val featureFlagService: FeatureFlagService, ) : ElementClassicConnection { // Messenger for communicating with the service. private var messenger: Messenger? = null @@ -116,6 +119,10 @@ class DefaultElementClassicConnection( override fun start() { Timber.tag(loggerTag.value).d("start()") coroutineScope.launch { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SignInWithClassic)) { + Timber.tag(loggerTag.value).d("Login with Element Classic is disabled, not starting connection") + return@launch + } // Establish a connection with the service. We use an explicit // class name because there is no reason to be able to let other // applications replace our component. @@ -151,6 +158,11 @@ class DefaultElementClassicConnection( override fun requestSession() { Timber.tag(loggerTag.value).d("requestSession()") coroutineScope.launch { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SignInWithClassic)) { + Timber.tag(loggerTag.value).d("Login with Element Classic is disabled") + emitState(ElementClassicConnectionState.Error("The feature is disabled")) + return@launch + } val finalMessenger = messenger if (finalMessenger == null) { Timber.tag(loggerTag.value).d("The messenger is null, can't request data") diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt index 8ea1b2e3d3..5da3c97f3c 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -15,6 +15,9 @@ import androidx.core.graphics.createBitmap import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.service.ServiceBinder +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.auth.ElementClassicSession import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -109,6 +112,21 @@ class DefaultElementClassicConnectionTest { } } + @Test + fun `requestSession when the feature is disabled emits an error`() = runTest { + val connection = createDefaultElementClassicConnection( + matrixAuthenticationService = FakeMatrixAuthenticationService( + setElementClassicSessionResult = {}, + ), + isFeatureEnabled = false, + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + connection.requestSession() + assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java) + } + } + @Test fun `when an error is received, an error is emitted`() = runTest { val connection = createDefaultElementClassicConnection( @@ -496,10 +514,17 @@ class DefaultElementClassicConnectionTest { homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( checkResult = { Result.success(true) } ), + isFeatureEnabled: Boolean = true, + featureFlagService: FeatureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SignInWithClassic.key to isFeatureEnabled, + ) + ), ) = DefaultElementClassicConnection( serviceBinder = serviceBinder, coroutineScope = coroutineScope, matrixAuthenticationService = matrixAuthenticationService, homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker, + featureFlagService = featureFlagService, ) } From 1b03dd1dcbc109fda1a533b5797a1623732b19ac Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Apr 2026 10:31:54 +0200 Subject: [PATCH 28/53] Split developer settings into 2 screens to be able to access global settings when no logged in. --- features/login/impl/build.gradle.kts | 1 + .../features/login/impl/LoginFlowNode.kt | 21 +++ .../impl/screens/onboarding/OnBoardingNode.kt | 2 + .../screens/onboarding/OnBoardingPresenter.kt | 2 + .../screens/onboarding/OnBoardingState.kt | 1 + .../onboarding/OnBoardingStateProvider.kt | 3 + .../impl/screens/onboarding/OnBoardingView.kt | 16 ++ .../preferences/api/PreferencesEntryPoint.kt | 10 ++ .../impl/DefaultPreferencesEntryPoint.kt | 12 ++ .../impl/developer/DeveloperSettingsEvents.kt | 7 - .../developer/DeveloperSettingsPresenter.kt | 134 +------------- .../impl/developer/DeveloperSettingsState.kt | 17 +- .../DeveloperSettingsStateProvider.kt | 29 +-- .../impl/developer/DeveloperSettingsView.kt | 114 +----------- .../appsettings/AppDeveloperSettingsEvent.kt | 19 ++ .../appsettings/AppDeveloperSettingsNode.kt | 50 ++++++ .../appsettings/AppDeveloperSettingsPage.kt | 54 ++++++ .../AppDeveloperSettingsPresenter.kt | 168 ++++++++++++++++++ .../appsettings/AppDeveloperSettingsState.kt | 29 +++ .../AppDeveloperSettingsStateProvider.kt | 49 +++++ .../appsettings/AppDeveloperSettingsView.kt | 161 +++++++++++++++++ .../developer/di/DeveloperSettingsModule.kt | 23 +++ .../DeveloperSettingsPresenterTest.kt | 128 +------------ .../developer/DeveloperSettingsViewTest.kt | 40 +---- .../AppDeveloperSettingsPageTest.kt | 113 ++++++++++++ .../AppDeveloperSettingsPresenterTest.kt | 168 ++++++++++++++++++ 26 files changed, 924 insertions(+), 447 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/di/DeveloperSettingsModule.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index f19ba61783..83dd4cf7bd 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -55,6 +55,7 @@ setupDependencyInjection() dependencies { implementation(projects.appconfig) implementation(projects.features.enterprise.api) + implementation(projects.features.preferences.api) implementation(projects.features.rageshake.api) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) 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 bc928a936f..fb384d505a 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 @@ -41,6 +41,7 @@ import io.element.android.features.login.impl.screens.createaccount.CreateAccoun 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 import io.element.android.libraries.architecture.BaseFlowNode @@ -67,6 +68,7 @@ class LoginFlowNode( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val elementClassicConnection: ElementClassicConnection, + private val preferencesEntryPoint: PreferencesEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.CheckClassicFlow, @@ -117,6 +119,9 @@ class LoginFlowNode( @Parcelize data object QrCode : NavTarget + @Parcelize + data object AppDeveloperSettings : NavTarget + @Parcelize data class ConfirmAccountProvider( val isAccountCreation: Boolean, @@ -200,6 +205,10 @@ class LoginFlowNode( backstack.push(NavTarget.CreateAccount(url)) } + override fun navigateToDeveloperSettings() { + backstack.push(NavTarget.AppDeveloperSettings) + } + override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword()) } @@ -220,6 +229,18 @@ class LoginFlowNode( ) createNode(buildContext, listOf(callback, inputs)) } + NavTarget.AppDeveloperSettings -> { + val callback = object : PreferencesEntryPoint.DeveloperSettingsCallback { + override fun onDone() { + backstack.pop() + } + } + preferencesEntryPoint.createAppDeveloperSettingsNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } NavTarget.ChooseAccountProvider -> { val callback = object : ChooseAccountProviderNode.Callback { override fun navigateToOidc(oidcDetails: OidcDetails) { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 030d65eae9..5572c412a0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -42,6 +42,7 @@ class OnBoardingNode( fun navigateToLoginPassword() fun navigateToOidc(oidcDetails: OidcDetails) fun navigateToCreateAccount(url: String) + fun navigateToDeveloperSettings() fun onDone() } @@ -75,6 +76,7 @@ class OnBoardingNode( onLearnMoreClick = { openLearnMorePage(context) }, onCreateAccountContinue = callback::navigateToCreateAccount, onBackClick = callback::onDone, + onDeveloperSettingsClick = callback::navigateToDeveloperSettings, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 60fa34f4d0..306549d11b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -29,6 +29,7 @@ import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.ui.utils.MultipleTapToUnlock import kotlinx.coroutines.launch @@ -125,6 +126,7 @@ class OnBoardingPresenter( return OnBoardingState( isAddingAccount = isAddingAccount, showBackButton = params.showBackButton, + showDeveloperSettings = buildMeta.buildType != BuildType.RELEASE, productionApplicationName = buildMeta.productionApplicationName, defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index a1c49e0d45..316efb03ef 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncData data class OnBoardingState( val isAddingAccount: Boolean, val showBackButton: Boolean, + val showDeveloperSettings: Boolean, val productionApplicationName: String, val defaultAccountProvider: String?, val mustChooseAccountProvider: Boolean, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index ec76404686..249a904dc3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -31,6 +31,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider { ), anOnBoardingState( showBackButton = true, + showDeveloperSettings = true, ), ) } @@ -38,6 +39,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider { fun anOnBoardingState( isAddingAccount: Boolean = false, showBackButton: Boolean = false, + showDeveloperSettings: Boolean = false, productionApplicationName: String = "Element", defaultAccountProvider: String? = null, mustChooseAccountProvider: Boolean = false, @@ -52,6 +54,7 @@ fun anOnBoardingState( ) = OnBoardingState( isAddingAccount = isAddingAccount, showBackButton = showBackButton, + showDeveloperSettings = showDeveloperSettings, productionApplicationName = productionApplicationName, defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 6549e21e41..5ee7ab6ac4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -64,6 +64,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun OnBoardingView( state: OnBoardingState, onBackClick: () -> Unit, + onDeveloperSettingsClick: () -> Unit, onSignInWithQrCode: () -> Unit, onSignIn: (mustChooseAccountProvider: Boolean) -> Unit, onCreateAccount: () -> Unit, @@ -110,6 +111,7 @@ fun OnBoardingView( loginView = loginView, buttons = buttons, onBackClick = onBackClick, + onDeveloperSettingsClick = onDeveloperSettingsClick, ) } } @@ -120,6 +122,7 @@ private fun AddFirstAccountScaffold( loginView: @Composable () -> Unit, buttons: @Composable () -> Unit, onBackClick: () -> Unit, + onDeveloperSettingsClick: () -> Unit, modifier: Modifier = Modifier, ) { OnBoardingPage( @@ -136,6 +139,18 @@ private fun AddFirstAccountScaffold( } else { OnBoardingContent(state = state) } + if (state.showDeveloperSettings) { + IconButton( + onClick = onDeveloperSettingsClick, + modifier = Modifier + .align(Alignment.TopStart), + ) { + Icon( + imageVector = CompoundIcons.SettingsSolid(), + contentDescription = stringResource(CommonStrings.common_developer_options), + ) + } + } if (state.showBackButton) { // Add icon button to "navigate back" IconButton( @@ -334,6 +349,7 @@ internal fun OnBoardingViewPreview( OnBoardingView( state = state, onBackClick = {}, + onDeveloperSettingsClick = {}, onSignInWithQrCode = {}, onSignIn = {}, onCreateAccount = {}, diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 04b471a498..e7fbe6069f 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -50,4 +50,14 @@ interface PreferencesEntryPoint : FeatureEntryPoint { fun navigateToRoomNotificationSettings(roomId: RoomId) fun navigateToEvent(roomId: RoomId, eventId: EventId) } + + fun createAppDeveloperSettingsNode( + parentNode: Node, + buildContext: BuildContext, + callback: DeveloperSettingsCallback, + ): Node + + interface DeveloperSettingsCallback : Plugin { + fun onDone() + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt index 57c561400c..bacf1bfb48 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsNode import io.element.android.libraries.architecture.createNode @ContributesBinding(AppScope::class) @@ -28,6 +29,17 @@ class DefaultPreferencesEntryPoint : PreferencesEntryPoint { plugins = listOf(params, callback) ) } + + override fun createAppDeveloperSettingsNode( + parentNode: Node, + buildContext: BuildContext, + callback: PreferencesEntryPoint.DeveloperSettingsCallback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(callback), + ) + } } internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index 1804d7e070..12de2be746 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -9,15 +9,8 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.ui.graphics.Color -import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem -import io.element.android.libraries.featureflag.ui.model.FeatureUiModel -import io.element.android.libraries.matrix.api.tracing.TraceLogPack sealed interface DeveloperSettingsEvents { - data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents - data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents - data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents - data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index a0d96be540..1598c2ef27 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -11,61 +11,37 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateListOf 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 androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.toArgb import dev.zacsweers.metro.Inject import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.preferences.impl.developer.tracing.toLogLevel -import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem -import io.element.android.features.preferences.impl.model.EnabledFeature +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase -import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.data.ByteUnit -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.core.meta.BuildType -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.net.URL @Inject class DeveloperSettingsPresenter( + private val appDeveloperSettingsPresenter: Presenter, private val sessionId: SessionId, - private val featureFlagService: FeatureFlagService, private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, private val clearCacheUseCase: ClearCacheUseCase, - private val rageshakePresenter: Presenter, - private val appPreferencesStore: AppPreferencesStore, - private val buildMeta: BuildMeta, private val enterpriseService: EnterpriseService, private val vacuumStoresUseCase: VacuumStoresUseCase, private val databaseSizesUseCase: GetDatabaseSizesUseCase, @@ -73,10 +49,6 @@ class DeveloperSettingsPresenter( ) : Presenter { @Composable override fun present(): DeveloperSettingsState { - val rageshakeState = rageshakePresenter.present() - val enabledFeatures = remember { - mutableStateListOf() - } val cacheSize = remember { mutableStateOf>(AsyncData.Uninitialized) } @@ -89,38 +61,9 @@ class DeveloperSettingsPresenter( var showColorPicker by remember { mutableStateOf(false) } - val customElementCallBaseUrl by remember { - appPreferencesStore - .getCustomElementCallBaseUrlFlow() - }.collectAsState(initial = null) - - val tracingLogLevelFlow = remember { - appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) } - } - val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized) - val tracingLogPacks by produceState(persistentListOf()) { - appPreferencesStore.getTracingLogPacksFlow() - // Sort the entries alphabetically by its title - .map { it.sortedBy { pack -> pack.title } } - .collectLatest { value = it.toImmutableList() } - } - LaunchedEffect(Unit) { computeDatabaseSizes(databaseSizes) - featureFlagService.getAvailableFeatures() - .run { - // Never display room directory search in release builds for Play Store - if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) { - filterNot { it.key == FeatureFlags.RoomDirectorySearch.key } - } else { - this - } - } - .forEach { feature -> - enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature))) - } } - val featureUiModels = createUiModels(enabledFeatures) val coroutineScope = rememberCoroutineScope() // Compute cache size each time the clear cache action value is changed LaunchedEffect(clearCacheAction.value.isSuccess()) { @@ -129,29 +72,7 @@ class DeveloperSettingsPresenter( fun handleEvent(event: DeveloperSettingsEvents) { when (event) { - is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( - enabledFeatures = enabledFeatures, - featureKey = event.feature.key, - enabled = event.isEnabled, - triggerClearCache = { handleEvent(DeveloperSettingsEvents.ClearCache) } - ) - is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch { - val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() } - appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) - } DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) - is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch { - appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel()) - } - is DeveloperSettingsEvents.ToggleTracingLogPack -> coroutineScope.launch { - val currentPacks = tracingLogPacks.toMutableSet() - if (currentPacks.contains(event.logPack)) { - currentPacks.remove(event.logPack) - } else { - currentPacks.add(event.logPack) - } - appPreferencesStore.setTracingLogPacks(currentPacks) - } is DeveloperSettingsEvents.ChangeBrandColor -> coroutineScope.launch { showColorPicker = false val color = event.color @@ -170,56 +91,18 @@ class DeveloperSettingsPresenter( } } + val appDeveloperSettingsState = appDeveloperSettingsPresenter.present() return DeveloperSettingsState( - features = featureUiModels, + appDeveloperSettingsState = appDeveloperSettingsState, cacheSize = cacheSize.value, databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, - rageshakeState = rageshakeState, - customElementCallBaseUrlState = CustomElementCallBaseUrlState( - baseUrl = customElementCallBaseUrl, - validator = ::customElementCallUrlValidator, - ), - tracingLogLevel = tracingLogLevel, - tracingLogPacks = tracingLogPacks, isEnterpriseBuild = enterpriseService.isEnterpriseBuild, showColorPicker = showColorPicker, eventSink = ::handleEvent, ) } - @Composable - private fun createUiModels( - enabledFeatures: SnapshotStateList, - ): ImmutableList { - return enabledFeatures.map { enabledFeature -> - key(enabledFeature.feature.key) { - remember(enabledFeature) { - FeatureUiModel( - key = enabledFeature.feature.key, - title = enabledFeature.feature.title, - description = enabledFeature.feature.description, - icon = null, - isEnabled = enabledFeature.isEnabled - ) - } - } - }.toImmutableList() - } - - private fun CoroutineScope.updateEnabledFeature( - enabledFeatures: SnapshotStateList, - featureKey: String, - enabled: Boolean, - @Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit, - ) = launch { - val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch - val feature = enabledFeatures[featureIndex].feature - if (featureFlagService.setFeatureEnabled(feature, enabled)) { - enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled) - } - } - private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { suspend { computeCacheSizeUseCase() @@ -253,12 +136,3 @@ class DeveloperSettingsPresenter( }.runCatchingUpdatingState(clearCacheAction) } } - -private fun customElementCallUrlValidator(url: String?): Boolean { - return runCatchingExceptions { - if (url.isNullOrEmpty()) return@runCatchingExceptions - val parsedUrl = URL(url) - if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol") - if (parsedUrl.host.isNullOrBlank()) error("Missing host") - }.isSuccess -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 920c8ec95c..fa5859a028 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -8,32 +8,19 @@ package io.element.android.features.preferences.impl.developer -import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem -import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.featureflag.ui.model.FeatureUiModel -import io.element.android.libraries.matrix.api.tracing.TraceLogPack -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap data class DeveloperSettingsState( - val features: ImmutableList, + val appDeveloperSettingsState: AppDeveloperSettingsState, val cacheSize: AsyncData, val databaseSizes: AsyncData>, - val rageshakeState: RageshakePreferencesState, val clearCacheAction: AsyncAction, - val customElementCallBaseUrlState: CustomElementCallBaseUrlState, - val tracingLogLevel: AsyncData, - val tracingLogPacks: ImmutableList, val isEnterpriseBuild: Boolean, val showColorPicker: Boolean, val eventSink: (DeveloperSettingsEvents) -> Unit ) { val showLoader = clearCacheAction is AsyncAction.Loading } - -data class CustomElementCallBaseUrlState( - val baseUrl: String?, - val validator: (String?) -> Boolean, -) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index b925eabe9e..28aefd3ad1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -9,14 +9,11 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem -import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState +import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList -import io.element.android.libraries.matrix.api.tracing.TraceLogPack import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.toImmutableList open class DeveloperSettingsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -25,11 +22,6 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, - customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), - traceLogPacks: List = emptyList(), isEnterpriseBuild: Boolean = false, showColorPicker: Boolean = false, eventSink: (DeveloperSettingsEvents) -> Unit = {}, ) = DeveloperSettingsState( - features = aFeatureUiModelList(), - rageshakeState = aRageshakePreferencesState(), + appDeveloperSettingsState = appDeveloperSettingsState, cacheSize = AsyncData.Success("1.2 MB"), databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")), clearCacheAction = clearCacheAction, - customElementCallBaseUrlState = customElementCallBaseUrlState, - tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), - tracingLogPacks = traceLogPacks.toImmutableList(), isEnterpriseBuild = isEnterpriseBuild, showColorPicker = showColorPicker, eventSink = eventSink, ) - -fun aCustomElementCallBaseUrlState( - baseUrl: String? = null, - validator: (String?) -> Boolean = { true }, -) = CustomElementCallBaseUrlState( - baseUrl = baseUrl, - validator = validator, -) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 444a391d43..3adf9a13de 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -10,40 +10,28 @@ package io.element.android.features.preferences.impl.developer import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.progressSemantics -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -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.theme.ElementTheme import io.element.android.features.preferences.impl.R -import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem -import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory -import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferencePage -import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch -import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField 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.ListItem import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.featureflag.ui.FeatureListView -import io.element.android.libraries.featureflag.ui.model.FeatureUiModel -import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.ui.strings.CommonStrings import io.mhssn.colorpicker.ColorPickerDialog import io.mhssn.colorpicker.ColorPickerType -import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -71,52 +59,12 @@ fun DeveloperSettingsView( title = stringResource(id = CommonStrings.common_developer_options) ) { // Note: this is OK to hardcode strings in this debug screen. - PreferenceCategory( - title = "Feature flags", - ) { - FeatureListContent(state) - } - NotificationCategory(onPushHistoryClick) - ElementCallCategory(state = state) - - PreferenceCategory(title = "Rust SDK") { - PreferenceDropdown( - title = "Tracing log level", - supportingText = "Requires app reboot", - selectedOption = state.tracingLogLevel.dataOrNull(), - options = LogLevelItem.entries.toImmutableList(), - onSelectOption = { logLevel -> - state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(logLevel)) - } - ) - } - PreferenceCategory(title = "Enable trace logs per SDK feature") { - Text( - text = "Requires app reboot", - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) - ) - for (logPack in TraceLogPack.entries) { - PreferenceSwitch( - title = logPack.title, - isChecked = state.tracingLogPacks.contains(logPack), - onCheckedChange = { isChecked -> state.eventSink(DeveloperSettingsEvents.ToggleTracingLogPack(logPack, isChecked)) } - ) - } - } - - PreferenceCategory(title = "Showkase") { - ListItem( - headlineContent = { - Text("Open Showkase browser") - }, - onClick = onOpenShowkase - ) - } - RageshakePreferencesView( - state = state.rageshakeState, + AppDeveloperSettingsView( + state = state.appDeveloperSettingsState, + onOpenShowkase = onOpenShowkase, ) + NotificationCategory(onPushHistoryClick) + if (state.isEnterpriseBuild) { PreferenceCategory(title = "Theme") { ListItem( @@ -137,14 +85,6 @@ fun DeveloperSettingsView( ) } } - PreferenceCategory(title = "Crash") { - ListItem( - headlineContent = { - Text("Crash the app 💥") - }, - onClick = { error("This crash is a test.") } - ) - } val cache = state.cacheSize PreferenceCategory(title = "Cache") { ListItem( @@ -212,32 +152,6 @@ fun DeveloperSettingsView( ) } -@Composable -private fun ElementCallCategory( - state: DeveloperSettingsState, -) { - PreferenceCategory(title = "Element Call") { - val callUrlState = state.customElementCallBaseUrlState - - val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) { - stringResource(R.string.screen_advanced_settings_element_call_base_url_description) - } else { - callUrlState.baseUrl - } - PreferenceTextField( - headline = stringResource(R.string.screen_advanced_settings_element_call_base_url), - value = callUrlState.baseUrl, - placeholder = "https://.../room", - supportingText = supportingText, - validation = callUrlState.validator, - onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error), - displayValue = { value -> !value.isNullOrEmpty() }, - keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri), - onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) } - ) - } -} - @Composable private fun NotificationCategory(onPushHistoryClick: () -> Unit) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { @@ -250,20 +164,6 @@ private fun NotificationCategory(onPushHistoryClick: () -> Unit) { } } -@Composable -private fun FeatureListContent( - state: DeveloperSettingsState, -) { - fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) { - state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, isEnabled)) - } - - FeatureListView( - features = state.features, - onCheckedChange = ::onFeatureEnabled, - ) -} - @PreviewsDayNight @Composable internal fun DeveloperSettingsViewPreview( @@ -273,6 +173,6 @@ internal fun DeveloperSettingsViewPreview( state = state, onOpenShowkase = {}, onPushHistoryClick = {}, - onBackClick = {} + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt new file mode 100644 index 0000000000..d9641a2810 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.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.preferences.impl.developer.appsettings + +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack + +sealed interface AppDeveloperSettingsEvent { + data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : AppDeveloperSettingsEvent + data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent + data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent + data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt new file mode 100644 index 0000000000..ae5e710d4b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer.appsettings + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.airbnb.android.showkase.models.Showkase +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.preferences.api.PreferencesEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.designsystem.showkase.getBrowserIntent + +@ContributesNode(AppScope::class) +@AssistedInject +class AppDeveloperSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AppDeveloperSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + private val callback: PreferencesEntryPoint.DeveloperSettingsCallback = callback() + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + fun openShowkase() { + val intent = Showkase.getBrowserIntent(activity) + activity.startActivity(intent) + } + + val state = presenter.present() + AppDeveloperSettingsPage( + state = state, + modifier = modifier, + onOpenShowkase = ::openShowkase, + onBackClick = callback::onDone, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt new file mode 100644 index 0000000000..81e1304e7b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt @@ -0,0 +1,54 @@ +/* + * 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.impl.developer.appsettings + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AppDeveloperSettingsPage( + state: AppDeveloperSettingsState, + onOpenShowkase: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler( + onBack = onBackClick, + ) + PreferencePage( + modifier = modifier, + onBackClick = { + onBackClick() + }, + title = "Application developer options", + ) { + AppDeveloperSettingsView( + state = state, + onOpenShowkase = onOpenShowkase, + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +@PreviewsDayNight +@Composable +internal fun AppDeveloperSettingsPagePreview() = ElementPreview { + AppDeveloperSettingsPage( + state = anAppDeveloperSettingsState(), + onOpenShowkase = {}, + onBackClick = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt new file mode 100644 index 0000000000..4c76c6ec7e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt @@ -0,0 +1,168 @@ +/* + * 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.impl.developer.appsettings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.SnapshotStateList +import dev.zacsweers.metro.Inject +import io.element.android.features.preferences.impl.developer.tracing.toLogLevel +import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem +import io.element.android.features.preferences.impl.model.EnabledFeature +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.net.URL + +@Inject +class AppDeveloperSettingsPresenter( + private val featureFlagService: FeatureFlagService, + private val rageshakePresenter: Presenter, + private val appPreferencesStore: AppPreferencesStore, + private val buildMeta: BuildMeta, +) : Presenter { + @Composable + override fun present(): AppDeveloperSettingsState { + val rageshakeState = rageshakePresenter.present() + val enabledFeatures = remember { + mutableStateListOf() + } + val customElementCallBaseUrl by remember { + appPreferencesStore + .getCustomElementCallBaseUrlFlow() + }.collectAsState(initial = null) + + val tracingLogLevelFlow = remember { + appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) } + } + val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized) + val tracingLogPacks by produceState(persistentListOf()) { + appPreferencesStore.getTracingLogPacksFlow() + // Sort the entries alphabetically by its title + .map { it.sortedBy { pack -> pack.title } } + .collectLatest { value = it.toImmutableList() } + } + + LaunchedEffect(Unit) { + featureFlagService.getAvailableFeatures() + .run { + // Never display room directory search in release builds for Play Store + if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) { + filterNot { it.key == FeatureFlags.RoomDirectorySearch.key } + } else { + this + } + } + .forEach { feature -> + enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature))) + } + } + val featureUiModels = createUiModels(enabledFeatures) + val coroutineScope = rememberCoroutineScope() + // Compute cache size each time the clear cache action value is changed + + fun handleEvent(event: AppDeveloperSettingsEvent) { + when (event) { + is AppDeveloperSettingsEvent.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( + enabledFeatures = enabledFeatures, + featureKey = event.feature.key, + enabled = event.isEnabled, + ) + is AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl -> coroutineScope.launch { + val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() } + appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) + } + is AppDeveloperSettingsEvent.SetTracingLogLevel -> coroutineScope.launch { + appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel()) + } + is AppDeveloperSettingsEvent.ToggleTracingLogPack -> coroutineScope.launch { + val currentPacks = tracingLogPacks.toMutableSet() + if (currentPacks.contains(event.logPack)) { + currentPacks.remove(event.logPack) + } else { + currentPacks.add(event.logPack) + } + appPreferencesStore.setTracingLogPacks(currentPacks) + } + } + } + + return AppDeveloperSettingsState( + features = featureUiModels, + rageshakeState = rageshakeState, + customElementCallBaseUrlState = CustomElementCallBaseUrlState( + baseUrl = customElementCallBaseUrl, + validator = ::customElementCallUrlValidator, + ), + tracingLogLevel = tracingLogLevel, + tracingLogPacks = tracingLogPacks, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun createUiModels( + enabledFeatures: SnapshotStateList, + ): ImmutableList { + return enabledFeatures.map { enabledFeature -> + key(enabledFeature.feature.key) { + remember(enabledFeature) { + FeatureUiModel( + key = enabledFeature.feature.key, + title = enabledFeature.feature.title, + description = enabledFeature.feature.description, + icon = null, + isEnabled = enabledFeature.isEnabled + ) + } + } + }.toImmutableList() + } + + private fun CoroutineScope.updateEnabledFeature( + enabledFeatures: SnapshotStateList, + featureKey: String, + enabled: Boolean, + ) = launch { + val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch + val feature = enabledFeatures[featureIndex].feature + if (featureFlagService.setFeatureEnabled(feature, enabled)) { + enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled) + } + } +} + +private fun customElementCallUrlValidator(url: String?): Boolean { + return runCatchingExceptions { + if (url.isNullOrEmpty()) return@runCatchingExceptions + val parsedUrl = URL(url) + if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol") + if (parsedUrl.host.isNullOrBlank()) error("Missing host") + }.isSuccess +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt new file mode 100644 index 0000000000..1eb5fd7fd3 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.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.features.preferences.impl.developer.appsettings + +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.collections.immutable.ImmutableList + +data class AppDeveloperSettingsState( + val features: ImmutableList, + val rageshakeState: RageshakePreferencesState, + val customElementCallBaseUrlState: CustomElementCallBaseUrlState, + val tracingLogLevel: AsyncData, + val tracingLogPacks: ImmutableList, + val eventSink: (AppDeveloperSettingsEvent) -> Unit +) + +data class CustomElementCallBaseUrlState( + val baseUrl: String?, + val validator: (String?) -> Boolean, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt new file mode 100644 index 0000000000..494b3b6bbd --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt @@ -0,0 +1,49 @@ +/* + * 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.impl.developer.appsettings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.collections.immutable.toImmutableList + +open class AppDeveloperSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAppDeveloperSettingsState(), + anAppDeveloperSettingsState( + customElementCallBaseUrlState = aCustomElementCallBaseUrlState( + baseUrl = "https://call.element.ahoy", + ) + ), + ) +} + +fun anAppDeveloperSettingsState( + customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), + traceLogPacks: List = emptyList(), + eventSink: (AppDeveloperSettingsEvent) -> Unit = {}, +) = AppDeveloperSettingsState( + features = aFeatureUiModelList(), + rageshakeState = aRageshakePreferencesState(), + customElementCallBaseUrlState = customElementCallBaseUrlState, + tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), + tracingLogPacks = traceLogPacks.toImmutableList(), + eventSink = eventSink, +) + +fun aCustomElementCallBaseUrlState( + baseUrl: String? = null, + validator: (String?) -> Boolean = { true }, +) = CustomElementCallBaseUrlState( + baseUrl = baseUrl, + validator = validator, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt new file mode 100644 index 0000000000..71051cf829 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -0,0 +1,161 @@ +/* + * 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.impl.developer.appsettings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.featureflag.ui.FeatureListView +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AppDeveloperSettingsView( + state: AppDeveloperSettingsState, + onOpenShowkase: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + // Note: this is OK to hardcode strings in this debug screen. + PreferenceCategory( + title = "Feature flags", + showTopDivider = false, + ) { + FeatureListContent(state) + } + ElementCallCategory(state = state) + PreferenceCategory(title = "Rust SDK") { + PreferenceDropdown( + title = "Tracing log level", + supportingText = "Requires app reboot", + selectedOption = state.tracingLogLevel.dataOrNull(), + options = LogLevelItem.entries.toImmutableList(), + onSelectOption = { logLevel -> + state.eventSink(AppDeveloperSettingsEvent.SetTracingLogLevel(logLevel)) + } + ) + } + PreferenceCategory(title = "Enable trace logs per SDK feature") { + Text( + text = "Requires app reboot", + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + for (logPack in TraceLogPack.entries) { + PreferenceSwitch( + title = logPack.title, + isChecked = state.tracingLogPacks.contains(logPack), + onCheckedChange = { isChecked -> state.eventSink(AppDeveloperSettingsEvent.ToggleTracingLogPack(logPack, isChecked)) } + ) + } + } + PreferenceCategory(title = "Showkase") { + ListItem( + headlineContent = { + Text("Open Showkase browser") + }, + onClick = onOpenShowkase + ) + } + PreferenceCategory(title = "Crash") { + ListItem( + headlineContent = { + Text("Crash the app 💥") + }, + onClick = { error("This crash is a test.") } + ) + } + RageshakePreferencesView( + state = state.rageshakeState, + ) + PreferenceCategory(title = "Crash") { + ListItem( + headlineContent = { + Text("Crash the app 💥") + }, + onClick = { error("This crash is a test.") } + ) + } + } +} + +@Composable +private fun ElementCallCategory( + state: AppDeveloperSettingsState, +) { + PreferenceCategory(title = "Element Call") { + val callUrlState = state.customElementCallBaseUrlState + + val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) { + stringResource(R.string.screen_advanced_settings_element_call_base_url_description) + } else { + callUrlState.baseUrl + } + PreferenceTextField( + headline = stringResource(R.string.screen_advanced_settings_element_call_base_url), + value = callUrlState.baseUrl, + placeholder = "https://.../room", + supportingText = supportingText, + validation = callUrlState.validator, + onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error), + displayValue = { value -> !value.isNullOrEmpty() }, + keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri), + onChange = { state.eventSink(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl(it)) } + ) + } +} + +@Composable +private fun FeatureListContent( + state: AppDeveloperSettingsState, +) { + fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) { + state.eventSink(AppDeveloperSettingsEvent.UpdateEnabledFeature(feature, isEnabled)) + } + + FeatureListView( + features = state.features, + onCheckedChange = ::onFeatureEnabled, + ) +} + +@PreviewsDayNight +@Composable +internal fun AppDeveloperSettingsViewPreview( + @PreviewParameter(AppDeveloperSettingsStateProvider::class) state: AppDeveloperSettingsState +) = ElementPreview { + AppDeveloperSettingsView( + state = state, + onOpenShowkase = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/di/DeveloperSettingsModule.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/di/DeveloperSettingsModule.kt new file mode 100644 index 0000000000..bad0ccae0f --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/di/DeveloperSettingsModule.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.preferences.impl.developer.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsPresenter +import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState +import io.element.android.libraries.architecture.Presenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface DeveloperSettingsModule { + @Binds + fun bindAppDeveloperSettingsPresenter(presenter: AppDeveloperSettingsPresenter): Presenter +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 1fcf9bff70..ec70b19eab 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -14,27 +14,18 @@ import androidx.compose.ui.graphics.Color 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.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase -import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.megaBytes -import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.core.meta.BuildType -import io.element.android.libraries.featureflag.api.Feature -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeature -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.core.aBuildMeta -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 @@ -51,17 +42,7 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures initial states are correct`() = runTest { - val getAvailableFeaturesResult = lambdaRecorder> { _, _ -> - listOf( - FakeFeature( - key = "feature_1", - title = "Feature 1", - isInLabs = false, - ) - ) - } val presenter = createDeveloperSettingsPresenter( - featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult), databaseSizesUseCase = GetDatabaseSizesUseCase { Result.success( SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes) @@ -70,22 +51,14 @@ class DeveloperSettingsPresenterTest { ) presenter.test { awaitItem().also { state -> - assertThat(state.features).isEmpty() + assertThat(state.appDeveloperSettingsState.features).isNotEmpty() assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized) - assertThat(state.customElementCallBaseUrlState).isNotNull() - assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() - assertThat(state.rageshakeState.isEnabled).isFalse() - assertThat(state.rageshakeState.isSupported).isTrue() - assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) - assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized) assertThat(state.isEnterpriseBuild).isFalse() assertThat(state.showColorPicker).isFalse() } awaitItem().also { state -> - assertThat(state.features).isNotEmpty() - assertThat(state.features).hasSize(1) - assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO) + assertThat(state.cacheSize.isLoading()).isTrue() } awaitItem().also { state -> assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java) @@ -98,37 +71,6 @@ class DeveloperSettingsPresenterTest { ) ) } - getAvailableFeaturesResult.assertions().isCalledOnce() - .with(value(false), value(false)) - } - } - - @Test - fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest { - val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay") - val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta) - presenter.test { - skipItems(2) - awaitItem().also { state -> - assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch) - } - } - } - - @Test - fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { - val presenter = createDeveloperSettingsPresenter() - presenter.test { - skipItems(2) - awaitItem().also { state -> - val feature = state.features.first { !it.isEnabled } - state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled)) - } - awaitItem().also { state -> - val feature = state.features.first() - assertThat(feature.isEnabled).isTrue() - assertThat(feature.key).isEqualTo(feature.key) - } } } @@ -158,52 +100,6 @@ class DeveloperSettingsPresenterTest { } } - @Test - fun `present - custom element call base url`() = runTest { - val preferencesStore = InMemoryAppPreferencesStore() - val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore) - presenter.test { - skipItems(2) - awaitItem().also { state -> - assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() - state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy")) - } - awaitItem().also { state -> - assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy") - } - } - } - - @Test - fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest { - val presenter = createDeveloperSettingsPresenter() - presenter.test { - skipItems(2) - val urlValidator = awaitItem().customElementCallBaseUrlState.validator - assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one - assertThat(urlValidator("test")).isFalse() - assertThat(urlValidator("http://")).isFalse() - assertThat(urlValidator("geo://test")).isFalse() - assertThat(urlValidator("https://call.element.io")).isTrue() - } - } - - @Test - fun `present - changing tracing log level`() = runTest { - val preferences = InMemoryAppPreferencesStore() - val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences) - presenter.test { - skipItems(2) - awaitItem().also { state -> - assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO) - state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.TRACE)) - } - awaitItem().also { state -> - assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.TRACE) - } - } - } - @Test fun `present - enterprise build can change the brand color`() = runTest { val overrideBrandColorResult = lambdaRecorder { _, _ -> } @@ -250,33 +146,17 @@ class DeveloperSettingsPresenterTest { private fun createDeveloperSettingsPresenter( sessionId: SessionId = A_SESSION_ID, - featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( - getAvailableFeaturesResult = { _, _ -> - listOf( - FakeFeature( - key = "feature_1", - title = "Feature 1", - isInLabs = false, - ) - ) - } - ), cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), - preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), - buildMeta: BuildMeta = aBuildMeta(), enterpriseService: EnterpriseService = FakeEnterpriseService(), vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( + appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() }, sessionId = sessionId, - featureFlagService = featureFlagService, computeCacheSizeUseCase = cacheSizeUseCase, clearCacheUseCase = clearCacheUseCase, - rageshakePresenter = { aRageshakePreferencesState() }, - appPreferencesStore = preferencesStore, - buildMeta = buildMeta, enterpriseService = enterpriseService, vacuumStoresUseCase = vacuumStoresUseCase, databaseSizesUseCase = databaseSizesUseCase, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index 3854e3f4a1..d4d02d7de9 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -9,20 +9,12 @@ package io.element.android.features.preferences.impl.developer import androidx.activity.ComponentActivity -import androidx.compose.ui.test.filterToOne -import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.isDialog -import androidx.compose.ui.test.isEditable -import androidx.compose.ui.test.isFocusable import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.features.preferences.impl.R -import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem -import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn @@ -53,7 +45,7 @@ class DeveloperSettingsViewTest { } } - @Config(qualifiers = "h1500dp") + @Config(qualifiers = "h2000dp") @Test fun `clicking on push history notification invokes the expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -68,22 +60,6 @@ class DeveloperSettingsViewTest { } } - @Config(qualifiers = "h1500dp") - @Test - fun `clicking on element call url open the dialogs and submit emits the expected event`() { - val eventsRecorder = EventsRecorder() - rule.setDeveloperSettingsView( - state = aDeveloperSettingsState( - eventSink = eventsRecorder - ), - ) - rule.clickOn(R.string.screen_advanced_settings_element_call_base_url) - val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog())) - textInputNode.performTextInput("https://call.element.dev") - rule.clickOn(CommonStrings.action_ok) - eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev")) - } - @Config(qualifiers = "h2000dp") @Test fun `clicking on open showkase invokes the expected callback`() { @@ -99,20 +75,6 @@ class DeveloperSettingsViewTest { } } - @Config(qualifiers = "h1024dp") - @Test - fun `clicking on log level emits the expected event`() { - val eventsRecorder = EventsRecorder() - rule.setDeveloperSettingsView( - state = aDeveloperSettingsState( - eventSink = eventsRecorder - ), - ) - rule.onNodeWithText("Tracing log level").performClick() - rule.onNodeWithText("Debug").performClick() - eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG)) - } - @Config(qualifiers = "h2200dp") @Test fun `clicking on clear cache emits the expected event`() { diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt new file mode 100644 index 0000000000..123f31ae8e --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt @@ -0,0 +1,113 @@ +/* + * 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.impl.developer.appsettings + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isEditable +import androidx.compose.ui.test.isFocusable +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AppDeveloperSettingsPageTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAppDeveloperSettingsView( + state = anAppDeveloperSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Config(qualifiers = "h1500dp") + @Test + fun `clicking on element call url open the dialogs and submit emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAppDeveloperSettingsView( + state = anAppDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_element_call_base_url) + val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog())) + textInputNode.performTextInput("https://call.element.dev") + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev")) + } + + @Config(qualifiers = "h2000dp") + @Test + fun `clicking on open showkase invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAppDeveloperSettingsView( + state = anAppDeveloperSettingsState( + eventSink = eventsRecorder + ), + onOpenShowkase = it + ) + rule.onNodeWithText("Open Showkase browser").performClick() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on log level emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAppDeveloperSettingsView( + state = anAppDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Tracing log level").performClick() + rule.onNodeWithText("Debug").performClick() + eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.DEBUG)) + } +} + +private fun AndroidComposeTestRule.setAppDeveloperSettingsView( + state: AppDeveloperSettingsState, + onOpenShowkase: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AppDeveloperSettingsPage( + state = state, + onOpenShowkase = onOpenShowkase, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt new file mode 100644 index 0000000000..0e9d774e84 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt @@ -0,0 +1,168 @@ +/* + * 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.features.preferences.impl.developer.appsettings + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeature +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.core.aBuildMeta +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AppDeveloperSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - ensures initial states are correct`() = runTest { + val getAvailableFeaturesResult = lambdaRecorder> { _, _ -> + listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = false, + ) + ) + } + val presenter = createAppDeveloperSettingsPresenter( + featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult), + ) + presenter.test { + awaitItem().also { state -> + assertThat(state.features).isEmpty() + assertThat(state.customElementCallBaseUrlState).isNotNull() + assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() + assertThat(state.rageshakeState.isEnabled).isFalse() + assertThat(state.rageshakeState.isSupported).isTrue() + assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) + assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized) + } + awaitItem().also { state -> + assertThat(state.features).isNotEmpty() + assertThat(state.features).hasSize(1) + assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO) + } + getAvailableFeaturesResult.assertions().isCalledOnce() + .with(value(false), value(false)) + } + } + + @Test + fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest { + val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay") + val presenter = createAppDeveloperSettingsPresenter(buildMeta = buildMeta) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch) + } + } + } + + @Test + fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { + val presenter = createAppDeveloperSettingsPresenter() + presenter.test { + skipItems(1) + awaitItem().also { state -> + val feature = state.features.first { !it.isEnabled } + state.eventSink(AppDeveloperSettingsEvent.UpdateEnabledFeature(feature, !feature.isEnabled)) + } + awaitItem().also { state -> + val feature = state.features.first() + assertThat(feature.isEnabled).isTrue() + assertThat(feature.key).isEqualTo(feature.key) + } + } + } + + @Test + fun `present - custom element call base url`() = runTest { + val preferencesStore = InMemoryAppPreferencesStore() + val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferencesStore) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() + state.eventSink(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.ahoy")) + } + awaitItem().also { state -> + assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy") + } + } + } + + @Test + fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest { + val presenter = createAppDeveloperSettingsPresenter() + presenter.test { + skipItems(1) + val urlValidator = awaitItem().customElementCallBaseUrlState.validator + assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one + assertThat(urlValidator("test")).isFalse() + assertThat(urlValidator("http://")).isFalse() + assertThat(urlValidator("geo://test")).isFalse() + assertThat(urlValidator("https://call.element.io")).isTrue() + } + } + + @Test + fun `present - changing tracing log level`() = runTest { + val preferences = InMemoryAppPreferencesStore() + val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferences) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO) + state.eventSink(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.TRACE)) + } + awaitItem().also { state -> + assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.TRACE) + } + } + } + + private fun createAppDeveloperSettingsPresenter( + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( + getAvailableFeaturesResult = { _, _ -> + listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = false, + ) + ) + } + ), + preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + buildMeta: BuildMeta = aBuildMeta(), + ): AppDeveloperSettingsPresenter { + return AppDeveloperSettingsPresenter( + featureFlagService = featureFlagService, + rageshakePresenter = { aRageshakePreferencesState() }, + appPreferencesStore = preferencesStore, + buildMeta = buildMeta, + ) + } +} From 6d3b4aaaeef3f68145342e411a6986bd67a0c17b Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 15 Apr 2026 10:27:54 +0000 Subject: [PATCH 29/53] Update screenshots --- ....login.impl.screens.onboarding_OnBoardingView_Day_8_en.png | 4 ++-- ...ogin.impl.screens.onboarding_OnBoardingView_Night_8_en.png | 4 ++-- ...eveloper.appsettings_AppDeveloperSettingsPage_Day_0_en.png | 3 +++ ...eloper.appsettings_AppDeveloperSettingsPage_Night_0_en.png | 3 +++ ...eveloper.appsettings_AppDeveloperSettingsView_Day_0_en.png | 3 +++ ...eveloper.appsettings_AppDeveloperSettingsView_Day_1_en.png | 3 +++ ...eloper.appsettings_AppDeveloperSettingsView_Night_0_en.png | 3 +++ ...eloper.appsettings_AppDeveloperSettingsView_Night_1_en.png | 3 +++ ...ferences.impl.developer_DeveloperSettingsView_Day_0_en.png | 4 ++-- ...ferences.impl.developer_DeveloperSettingsView_Day_1_en.png | 4 ++-- ...ferences.impl.developer_DeveloperSettingsView_Day_2_en.png | 4 ++-- ...ferences.impl.developer_DeveloperSettingsView_Day_3_en.png | 3 --- ...rences.impl.developer_DeveloperSettingsView_Night_0_en.png | 4 ++-- ...rences.impl.developer_DeveloperSettingsView_Night_1_en.png | 4 ++-- ...rences.impl.developer_DeveloperSettingsView_Night_2_en.png | 4 ++-- ...rences.impl.developer_DeveloperSettingsView_Night_3_en.png | 3 --- 16 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png index b5f0eb7fcf..133535c6d5 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5015f504040a0141d40bc14bf8a3a3be43c9c95a3702a6dc53bb253746e5a3aa -size 311553 +oid sha256:01fa1c9b917b65afc2d1464fad177f7420dea1625eeb7c8335d8105664134e67 +size 312145 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png index 4669c0b972..30d5478a90 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8972af96ba5624f92c826ef0e20595b6193a44b6b0a0ea03cb133c516a93a90e -size 391678 +oid sha256:7e622d9b43664c5a31b83b41801ed07769384ab9ab84aad57605cdb67b16c58d +size 392254 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Day_0_en.png new file mode 100644 index 0000000000..708313c475 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62068492969ad00e1a8e4a44189f93d83c98fee40612e4eecb68d3076d00ed07 +size 56425 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Night_0_en.png new file mode 100644 index 0000000000..b69265cf41 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsPage_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d92e220d675097c37b60312ca4c4e821eb6ff6475bcd004437dde0d2e964cce +size 54756 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_0_en.png new file mode 100644 index 0000000000..b56ed34048 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf121c0ea1fb3cc7a47a292877535c9c0a1ae1ac11bf7f5ce169d0cd79844246 +size 53775 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_1_en.png new file mode 100644 index 0000000000..d0a5500209 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37ae25b9f9c659164c006c9bdb9f94379398ee8215e8a2dd9167620ef994b6d1 +size 52347 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_0_en.png new file mode 100644 index 0000000000..d2929c43be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db439869d2b8843cd58251bbca638e0dad7ed2b1ae4b22507e052e3d5521214d +size 52090 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_1_en.png new file mode 100644 index 0000000000..540b910b54 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer.appsettings_AppDeveloperSettingsView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cae56bb14ae7bc1f6c73884c2efb333b7da22c82d69026854a59c380657a5bbe +size 50705 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png index 4026c0e658..503b9ad61c 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e24299f25ffd7095887bb52214c25de8de2cf0380b374d11f65d78c86f49dae1 -size 45507 +oid sha256:d18a2e9ba19401f958483bf06ecd7b311ed7383de0df3d89f3bb4d3a57f8e9e7 +size 54191 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png index dd9d30850a..16be314a0d 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d84781b107e2f25bdc88cbfe84a1933dd20bf4c1dd372cb69f136f36df2607c0 -size 41951 +oid sha256:aafbc1f791f067fd0084db3bf293511e3cf7557329e7599c3f3e3c66f01435c4 +size 45472 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png index dff4e9fa71..503b9ad61c 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:577c00e6e45e1da5ac1b1deee380d7a087b1f32e077f8e5b9430497bf6f7012e -size 44083 +oid sha256:d18a2e9ba19401f958483bf06ecd7b311ed7383de0df3d89f3bb4d3a57f8e9e7 +size 54191 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png deleted file mode 100644 index 4026c0e658..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e24299f25ffd7095887bb52214c25de8de2cf0380b374d11f65d78c86f49dae1 -size 45507 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png index c8188fbaaa..430756b5b3 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9750c0ae52dce1ba63c1cff7a22a3e3c75c15b5a556ba7fff49815b55836372b -size 44198 +oid sha256:dcca87aa6eee7e45bedb8f58c3b776e3932d94843573eea70aa686c3de82e03f +size 52290 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png index d3c89a0735..4225562f66 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31a3e5f9abaed21c87052ef7642dc8456d75580b79988ebe271f09d1381e9a03 -size 40820 +oid sha256:25e311c9bd46defd9659004d2d36088985c0578189a41906b51bfe683ddb6488 +size 43889 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png index 5bfd54ce11..430756b5b3 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7aab145e8ca2cd9de64a145c7966420a474b3500016a46100dad798f33acba9 -size 42792 +oid sha256:dcca87aa6eee7e45bedb8f58c3b776e3932d94843573eea70aa686c3de82e03f +size 52290 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png deleted file mode 100644 index c8188fbaaa..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9750c0ae52dce1ba63c1cff7a22a3e3c75c15b5a556ba7fff49815b55836372b -size 44198 From 80470b37920a4f8443eb5f09d5be7ceedfe71378 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 15 Apr 2026 14:14:22 +0200 Subject: [PATCH 30/53] Feature: add room threads list (#6575) Add threads list screen for rooms: - Add `ThreadsListService` to subscribe to thread changes in the room. - Create `ThreadsListView` and its associated node a presenters (the UI may change). - Add a menu icon in the room screen to open it. This is still pending info about unread threads, so several UI components related to it will be hidden. * Add feature flag and use it to hide the access to this new screen --------- Co-authored-by: ElementBot --- .../messages/impl/MessagesFlowNode.kt | 16 + .../features/messages/impl/MessagesNode.kt | 3 + .../messages/impl/MessagesPresenter.kt | 17 + .../features/messages/impl/MessagesState.kt | 6 + .../messages/impl/MessagesStateProvider.kt | 5 + .../features/messages/impl/MessagesView.kt | 44 +- .../MessagesViewWithIdentityChangePreview.kt | 3 +- .../impl/threads/ThreadedMessagesNode.kt | 1 + .../impl/threads/list/ThreadListRowItem.kt | 17 + .../impl/threads/list/ThreadsListNode.kt | 44 ++ .../impl/threads/list/ThreadsListPresenter.kt | 141 +++++++ .../impl/threads/list/ThreadsListView.kt | 380 ++++++++++++++++++ .../impl/topbars/MessagesViewTopBar.kt | 31 +- .../messages/impl/MessagesPresenterTest.kt | 32 ++ .../messages/impl/MessagesViewTest.kt | 20 + .../fixtures/TimelineItemsFactoryFixtures.kt | 50 +-- .../impl/threads/ThreadsListPresenterTest.kt | 74 ++++ .../components/avatar/AvatarSize.kt | 2 + .../libraries/featureflag/api/FeatureFlags.kt | 7 + .../libraries/matrix/api/room/JoinedRoom.kt | 3 + .../matrix/api/room/threads/ThreadListItem.kt | 34 ++ .../threads/ThreadListPaginationStatus.kt | 16 + .../api/room/threads/ThreadsListService.kt | 18 + .../matrix/impl/room/JoinedRustRoom.kt | 10 + .../room/threads/RustThreadsListService.kt | 157 ++++++++ .../fakes/FakeFfiThreadListService.kt | 58 +++ .../threads/RustThreadsListServiceTest.kt | 143 +++++++ .../matrix/test/room/FakeJoinedRoom.kt | 2 + .../room/threads/FakeThreadsListService.kt | 48 +++ ...hreads.list_ThreadListItemRow_Day_0_en.png | 3 + ...eads.list_ThreadListItemRow_Night_0_en.png | 3 + ....threads.list_ThreadsListView_Day_0_en.png | 3 + ...hreads.list_ThreadsListView_Night_0_en.png | 3 + ...pl.topbars_MessagesViewTopBar_Day_0_en.png | 4 +- ....topbars_MessagesViewTopBar_Night_0_en.png | 4 +- 35 files changed, 1357 insertions(+), 45 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index f77f06fad3..646a19895a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.threads.ThreadedMessagesNode +import io.element.android.features.messages.impl.threads.list.ThreadsListNode import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -179,6 +180,9 @@ class MessagesFlowNode( @Parcelize data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget + + @Parcelize + data object ThreadsList : NavTarget } private val callback: MessagesEntryPoint.Callback = callback() @@ -294,6 +298,10 @@ class MessagesFlowNode( backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + override fun navigateToThreadsList() { + backstack.push(NavTarget.ThreadsList) + } + override fun navigateToDeveloperSettings() { callback.navigateToDeveloperSettings() } @@ -517,6 +525,14 @@ class MessagesFlowNode( } createNode(buildContext, listOf(inputs, callback)) } + NavTarget.ThreadsList -> { + val callback = object : ThreadsListNode.Callback { + override fun openThread(threadId: ThreadId) { + backstack.push(NavTarget.Thread(threadId, focusedEventId = null)) + } + } + createNode(buildContext, listOf(callback)) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 20cdc51035..a2cf4a3da0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -131,6 +131,8 @@ class MessagesNode( fun navigateToPinnedMessagesList() fun navigateToKnockRequestsList() fun navigateToDeveloperSettings() + + fun navigateToThreadsList() } override fun onBuilt() { @@ -299,6 +301,7 @@ class MessagesNode( onViewRequestsClick = callback::navigateToKnockRequestsList, ) }, + onThreadsListClick = callback::navigateToThreadsList, ) roomMemberModerationRenderer.Render( state = state.roomMemberModerationState, 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 edb11ff389..f115dd2799 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 @@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf 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.saveable.rememberSaveable @@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.appconfig.MessageComposerConfig import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.features.messages.impl.MessagesState.Threads import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState @@ -85,8 +87,11 @@ import io.element.android.libraries.recentemojis.api.AddRecentEmoji import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -160,6 +165,13 @@ class MessagesPresenter( val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val roomCallState = roomCallStatePresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present() + val threadsList by produceState(persistentListOf()) { + room.threadsListService.subscribeToItemUpdates() + .onStart { room.threadsListService.paginate() } + .collectLatest { value = it.toImmutableList() } + } + + val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false) val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> perms.userEventPermissions() @@ -294,6 +306,11 @@ class MessagesPresenter( roomMemberModerationState = roomMemberModerationState, topBarSharedHistoryIcon = topBarSharedHistoryIcon, successorRoom = roomInfo.successorRoom, + threads = Threads( + hasThreads = canOpenThreadList && threadsList.isNotEmpty(), + // TODO calculate this properly based on the thread list and the read state of each thread + hasUnreadThreads = false, + ), eventSink = ::handleEvent, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index c18fb461e0..862f30832b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -57,9 +57,15 @@ data class MessagesState( /** Type of "shared history" icon to show in the top bar. */ val topBarSharedHistoryIcon: SharedHistoryIcon, val successorRoom: SuccessorRoom?, + val threads: Threads, val eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null + + data class Threads( + val hasThreads: Boolean, + val hasUnreadThreads: Boolean, + ) } /** Type of "shared history" icon to show in the top bar. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index d969ae1491..16021df3e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -122,6 +122,10 @@ fun aMessagesState( roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, successorRoom: SuccessorRoom? = null, + threads: MessagesState.Threads = MessagesState.Threads( + hasThreads = false, + hasUnreadThreads = false, + ), eventSink: (MessagesEvent) -> Unit = {}, ) = MessagesState( roomId = RoomId("!id:domain"), @@ -150,6 +154,7 @@ fun aMessagesState( roomMemberModerationState = roomMemberModerationState, topBarSharedHistoryIcon = topBarSharedHistoryIcon, successorRoom = successorRoom, + threads = threads, eventSink = eventSink, ) 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 8e81ee74a7..bf20c8dc6b 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 @@ -12,10 +12,12 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,6 +55,7 @@ import androidx.compose.ui.tooling.preview.Preview 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.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListView @@ -74,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.aGroupedEvents import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.CallMenuItem 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.reactionsummary.ReactionSummaryEvent @@ -88,6 +93,7 @@ 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 import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog +import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout @@ -99,6 +105,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toAnnotatedString import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle +import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed @@ -133,6 +140,7 @@ fun MessagesView( onCreatePollClick: () -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit, onViewAllPinnedMessagesClick: () -> Unit, + onThreadsListClick: () -> Unit, modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, @@ -224,12 +232,18 @@ fun MessagesView( roomAvatar = state.roomAvatar, isTombstoned = state.isTombstoned, heroes = state.heroes, - roomCallState = state.roomCallState, dmUserIdentityState = state.dmUserVerificationState, sharedHistoryIcon = state.topBarSharedHistoryIcon, onBackClick = { hidingKeyboard { onBackClick() } }, onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - onJoinCallClick = onJoinCallClick, + menuActions = { + MessagesMenuActions( + displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, + roomCallState = state.roomCallState, + onJoinCallClick = onJoinCallClick, + onThreadsListClick = onThreadsListClick + ) + } ) } }, @@ -397,6 +411,28 @@ fun MessagesView( ) } +@Composable +internal fun MessagesMenuActions( + displayThreads: Boolean, + roomCallState: RoomCallState, + onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onThreadsListClick: () -> Unit, +) { + if (displayThreads) { + Icon( + modifier = Modifier.clickable(enabled = true, onClick = onThreadsListClick), + imageVector = CompoundIcons.ThreadsSolid(), + contentDescription = stringResource(CommonStrings.common_threads), + ) + Spacer(Modifier.width(8.dp)) + } + CallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = onJoinCallClick, + ) + Spacer(Modifier.width(8.dp)) +} + @Composable private fun ReinviteDialog(state: MessagesState) { if (state.showReinvitePrompt) { @@ -601,6 +637,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onViewAllPinnedMessagesClick = { }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, + onThreadsListClick = {}, ) } @@ -652,7 +689,8 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onSendLocationClick = {}, onCreatePollClick = {}, onJoinCallClick = {}, - onViewAllPinnedMessagesClick = { }, + onViewAllPinnedMessagesClick = {}, + onThreadsListClick = {}, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt index b434656f7a..2c1a1bbe23 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -41,6 +41,7 @@ internal fun MessagesViewWithIdentityChangePreview( onCreatePollClick = {}, onJoinCallClick = {}, onViewAllPinnedMessagesClick = {}, - knockRequestsBannerView = {} + knockRequestsBannerView = {}, + onThreadsListClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 4bb3471660..0949237862 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -300,6 +300,7 @@ class ThreadedMessagesNode( onViewAllPinnedMessagesClick = {}, modifier = modifier, knockRequestsBannerView = {}, + onThreadsListClick = {}, ) roomMemberModerationRenderer.Render( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.kt new file mode 100644 index 0000000000..3380a32f61 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.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.messages.impl.threads.list + +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem + +data class ThreadListRowItem( + val item: ThreadListItem, + val rootEventText: String?, + val latestEventText: String?, + val formattedTimestamp: String, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt new file mode 100644 index 0000000000..1954dc60a5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt @@ -0,0 +1,44 @@ +/* + * 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.threads.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.ThreadId + +@ContributesNode(RoomScope::class) +@AssistedInject +class ThreadsListNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ThreadsListPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun openThread(threadId: ThreadId) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + ThreadsListView( + state = presenter.present(), + modifier = modifier, + onThreadClick = callback::openThread, + onBackClick = this::navigateUp, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt new file mode 100644 index 0000000000..9d15376e9f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt @@ -0,0 +1,141 @@ +/* + * 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.threads.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class ThreadsListPresenter( + private val room: JoinedRoom, + private val timelineItemContentFactory: TimelineItemContentFactory, + private val messageSummaryFormatter: MessageSummaryFormatter, + private val dateFormatter: DateFormatter, +) : Presenter { + @Composable + override fun present(): ThreadsListState { + val coroutineScope = rememberCoroutineScope() + val threadsListService = room.threadsListService + + val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) { + threadsListService.subscribeToItemUpdates() + .onStart { threadsListService.paginate() } + .collect { items -> + Timber.d("Received thread list update with ${items.size} items") + value = items.map { item -> + val rootTimelineEvent = item.rootEvent.content?.let { + timelineItemContentFactory.create( + itemContent = it, + eventId = item.rootEvent.eventId, + isEditable = false, + sender = item.rootEvent.senderId, + senderProfile = item.rootEvent.senderProfile, + ) + } + val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) } + + val latestTimelineEvent = item.latestEvent?.content?.let { + timelineItemContentFactory.create( + itemContent = it, + eventId = item.latestEvent!!.eventId, + isEditable = false, + sender = item.latestEvent!!.senderId, + senderProfile = item.latestEvent!!.senderProfile, + ) + } + val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) } + + val formattedTimestamp = dateFormatter.format( + timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true, + ) + + ThreadListRowItem( + item = item, + rootEventText = rootEventText, + latestEventText = latestEventText, + formattedTimestamp = formattedTimestamp, + ) + }.toImmutableList() + } + } + + val paginationStatus by produceState( + initialValue = ThreadListPaginationStatus.Idle(hasMoreToLoad = true), + key1 = threadsListService + ) { + threadsListService + .subscribeToPaginationUpdates() + .collect { value = it } + } + + val roomInfo by room.roomInfoFlow.collectAsState() + + DisposableEffect(Unit) { + onDispose { + threadsListService.destroy() + } + } + + fun handleEvent(event: ThreadsListEvents) { + when (event) { + ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) { + coroutineScope.launch { + Timber.d("Paginating thread list: $paginationStatus") + threadsListService.paginate() + } + } else { + Timber.d("Not paginating since there is nothing else to load, current status: $paginationStatus") + } + } + } + + return ThreadsListState( + threads = threads, + roomId = room.roomId, + roomName = roomInfo.name ?: room.roomId.value, + roomAvatarUrl = roomInfo.avatarUrl, + isRoomTombstoned = roomInfo.successorRoom != null, + eventSink = ::handleEvent, + ) + } +} + +data class ThreadsListState( + val roomId: RoomId, + val roomName: String, + val roomAvatarUrl: String?, + val isRoomTombstoned: Boolean, + val threads: ImmutableList, + val eventSink: (ThreadsListEvents) -> Unit, +) + +sealed interface ThreadsListEvents { + data object Paginate : ThreadsListEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt new file mode 100644 index 0000000000..c93af5c162 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt @@ -0,0 +1,380 @@ +/* + * 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.threads.list + +import androidx.compose.foundation.clickable +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.height +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.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +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.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.Icon +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +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.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThreadsListView( + state: ThreadsListState, + onThreadClick: (ThreadId) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Avatar( + avatarData = AvatarData( + id = state.roomId.value, + name = state.roomName, + url = state.roomAvatarUrl, + size = AvatarSize.CurrentUserTopBar, + ), + avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned), + contentDescription = null, + ) + Column { + Text( + text = stringResource(CommonStrings.common_threads), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = state.roomName, + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + navigationIcon = { + BackButton(onBackClick) + } + ) + } + ) { padding -> + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = padding, + state = lazyListState, + ) { + itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row -> + ThreadListItemRow( + threadItem = row, + onClick = onThreadClick, + ) + + if (index < state.threads.size - 1) { + HorizontalDivider() + } + } + } + + ScrollHelper(lazyListState) { + state.eventSink(ThreadsListEvents.Paginate) + } + } +} + +@Composable +private fun ScrollHelper( + listState: LazyListState, + onPaginate: () -> Unit, +) { + val lastVisibleItemIndex by remember { + derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 } + } + val needsPagination by remember { + derivedStateOf { + val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0 + canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1 + } + } + LaunchedEffect(needsPagination, lastVisibleItemIndex) { + if (needsPagination) { + onPaginate() + delay(400L) + } + } +} + +@Composable +private fun ThreadListItemRow( + threadItem: ThreadListRowItem, + onClick: (ThreadId) -> Unit, +) { + Row( + modifier = Modifier + .clickable { onClick(threadItem.item.threadId) } + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp, start = 16.dp, end = 16.dp), + ) { + val rootEvent = threadItem.item.rootEvent + val senderProfile = rootEvent.senderProfile + Avatar( + modifier = Modifier.align(Alignment.CenterVertically), + avatarData = AvatarData( + id = rootEvent.senderId.value, + name = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId), + url = senderProfile.getAvatarUrl(), + size = AvatarSize.ThreadsListItem, + ), + avatarType = AvatarType.User, + contentDescription = null, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + // TODO actually compute these values based on the thread state (not available yet) + val hasMentions = false + val hasUnreadNotifications = false + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = threadItem.formattedTimestamp, + style = ElementTheme.typography.fontBodySmRegular, + color = if (hasUnreadNotifications || hasMentions) ElementTheme.colors.textActionAccent else ElementTheme.colors.textSecondary, + ) + } + + Spacer(modifier = Modifier.height(2.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = threadItem.rootEventText.orEmpty(), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp) + ) { + if (hasMentions) { + Icon( + modifier = Modifier.size(14.dp), + imageVector = CompoundIcons.Mention(), + contentDescription = null, + tint = ElementTheme.colors.textActionAccent, + ) + } + + UnreadIndicatorAtom( + size = 14.dp, + isVisible = hasUnreadNotifications, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${threadItem.item.numberOfReplies}", + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.ThreadsSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + threadItem.item.latestEvent?.let { latestEvent -> + Avatar( + avatarData = AvatarData( + id = latestEvent.senderId.value, + name = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId), + url = latestEvent.senderProfile.getAvatarUrl(), + size = AvatarSize.TimelineThreadLatestEventSender, + ), + avatarType = AvatarType.User, + contentDescription = null, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = threadItem.latestEventText.orEmpty(), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun ThreadsListViewPreview() { + ElementPreview { + ThreadsListView( + state = ThreadsListState( + roomId = RoomId("!room-id:server"), + roomName = "Room name", + roomAvatarUrl = null, + threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(), + isRoomTombstoned = false, + eventSink = {}, + ), + onThreadClick = {}, + onBackClick = {}, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ThreadListItemRowPreview() { + ElementPreview { + ThreadListItemRow( + threadItem = aThreadListRowItem(), + onClick = {}, + ) + } +} + +fun aThreadListRowItem( + threadId: ThreadId = ThreadId("\$a-thread-id"), + rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId), + latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId), + numberOfReplies: Long = 42, + rootEventText: String? = "Hello world!", + latestEventText: String? = "Hello again!", + formattedTimestamp: String = "12:34", +) = ThreadListRowItem( + item = aThreadListItem( + threadId = threadId, + rootEvent = rootEvent, + latestEvent = latestEvent, + numberOfReplies = numberOfReplies, + ), + rootEventText = rootEventText, + latestEventText = latestEventText, + formattedTimestamp = formattedTimestamp, +) + +fun aThreadListItem( + threadId: ThreadId = ThreadId("\$a-thread-id"), + rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId), + latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId), + numberOfReplies: Long = 42, +) = ThreadListItem( + rootEvent = rootEvent, + latestEvent = latestEvent, + numberOfReplies = numberOfReplies, +) + +fun aThreadListItemEvent( + threadId: ThreadId = ThreadId("\$a-thread-id"), + senderId: UserId = UserId("@a-user-id:server"), + senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null), + isOwn: Boolean = false, + content: EventContent = MessageContent( + body = "Hello world!", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType("Hello world!", null), + ), + timestamp: Long = 0L, +) = ThreadListItemEvent( + eventId = threadId.asEventId(), + senderId = senderId, + senderProfile = senderProfile, + isOwn = isOwn, + content = content, + timestamp = timestamp, +) 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 24cd71ae84..4d7242ebf5 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 @@ -12,10 +12,9 @@ import androidx.compose.foundation.clickable 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.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable @@ -30,8 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow 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.messages.impl.MessagesMenuActions import io.element.android.features.messages.impl.SharedHistoryIcon -import io.element.android.features.messages.impl.timeline.components.CallMenuItem import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.anOngoingCallState @@ -62,13 +61,12 @@ internal fun MessagesViewTopBar( roomAvatar: AvatarData, isTombstoned: Boolean, heroes: ImmutableList, - roomCallState: RoomCallState, dmUserIdentityState: IdentityState?, sharedHistoryIcon: SharedHistoryIcon, onRoomDetailsClick: () -> Unit, - onJoinCallClick: (isAudioCall: Boolean) -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, + menuActions: @Composable RowScope.() -> Unit, ) { TopAppBar( modifier = modifier, @@ -126,13 +124,7 @@ internal fun MessagesViewTopBar( } } }, - actions = { - CallMenuItem( - roomCallState = roomCallState, - onJoinCallClick = onJoinCallClick, - ) - Spacer(Modifier.width(8.dp)) - }, + actions = menuActions, windowInsets = WindowInsets(0.dp) ) } @@ -186,17 +178,24 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { roomCallState: RoomCallState = RoomCallState.Unavailable, dmUserIdentityState: IdentityState? = null, sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, + displayThreads: Boolean = false, ) = MessagesViewTopBar( roomName = roomName, roomAvatar = roomAvatar, isTombstoned = isTombstoned, heroes = heroes, - roomCallState = roomCallState, dmUserIdentityState = dmUserIdentityState, sharedHistoryIcon = sharedHistoryIcon, onRoomDetailsClick = {}, - onJoinCallClick = {}, onBackClick = {}, + menuActions = { + MessagesMenuActions( + roomCallState = roomCallState, + displayThreads = displayThreads, + onJoinCallClick = {}, + onThreadsListClick = {}, + ) + } ) Column { AMessagesViewTopBar() @@ -237,5 +236,9 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { roomName = "A room with world_readable history", sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE, ) + HorizontalDivider() + AMessagesViewTopBar( + displayThreads = true, + ) } } 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 f6967de0e5..6e12c607d8 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 @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState +import io.element.android.features.messages.impl.threads.list.aThreadListItem import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead import io.element.android.features.messages.impl.timeline.MarkAsFullyRead import io.element.android.features.messages.impl.timeline.TimelineController @@ -88,6 +89,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions +import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails @@ -110,6 +112,7 @@ import io.element.android.tests.testutils.testWithLifecycleOwner import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -1258,6 +1261,35 @@ class MessagesPresenterTest { } } + @Test + fun `present - only has threads enabled if the feature flag is on`() = runTest { + val itemsFlow = MutableStateFlow(listOf(aThreadListItem())) + val room = FakeJoinedRoom( + threadsListService = FakeThreadsListService(items = itemsFlow) + ) + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Threads.key to false) + ) + val presenter = createMessagesPresenter( + joinedRoom = room, + featureFlagService = featureFlagService + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + // The feature flag is disabled, so even if the thread list has items, it will return it doesn't have any + assertThat(initialState.threads.hasThreads).isFalse() + + // Enable the feature flag, now it should reflect the thread list state + featureFlagService.setFeatureEnabled(FeatureFlags.RoomThreadList, true) + skipItems(1) + assertThat(awaitItem().threads.hasThreads).isTrue() + + // And if we remove the items, it should update accordingly + itemsFlow.value = emptyList() + assertThat(awaitItem().threads.hasThreads).isFalse() + } + } + private fun roomPermissions( canStartCall: Boolean = true, canRedactOther: Boolean = true, 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 c78aa39265..ff4bc37fa3 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 @@ -73,6 +73,7 @@ import io.element.android.tests.testutils.assertNoNodeWithText 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.lambda.lambdaRecorder import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.setSafeContent import kotlinx.collections.immutable.persistentListOf @@ -606,6 +607,23 @@ class MessagesViewTest { eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId)) } + @Test + fun `clicking on threads list button calls the expected function`() { + val state = aMessagesState( + threads = MessagesState.Threads( + hasThreads = true, + hasUnreadThreads = false, + ) + ) + val onThreadsListClicked = lambdaRecorder {} + rule.setMessagesView( + state = state, + onThreadsListClicked = onThreadsListClicked, + ) + rule.onNodeWithContentDescription("Threads").performClick() + onThreadsListClicked.assertions().isCalledOnce() + } + @Test fun `no banner shown when there is no successor room`() { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -630,6 +648,7 @@ private fun AndroidComposeTestRule.setMessa onCreatePollClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), + onThreadsListClicked: () -> Unit = EnsureNeverCalled(), ) { setSafeContent { // Cannot use the RichTextEditor, so simulate a LocalInspectionMode @@ -646,6 +665,7 @@ private fun AndroidComposeTestRule.setMessa onJoinCallClick = onJoinCallClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, knockRequestsBannerView = {}, + onThreadsListClick = onThreadsListClicked, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index c7eb7c0bce..2f87af3df0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -48,38 +48,42 @@ internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Crea } } +internal fun aTimelineItemContentFactory( + timelineEventFormatter: TimelineEventFormatter = aTimelineEventFormatter(), + matrixClient: FakeMatrixClient = FakeMatrixClient(), +): TimelineItemContentFactory = TimelineItemContentFactory( + messageFactory = TimelineItemContentMessageFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + htmlConverterProvider = FakeHtmlConverterProvider(), + permalinkParser = FakePermalinkParser(), + textPillificationHelper = FakeTextPillificationHelper(), + ), + redactedMessageFactory = TimelineItemContentRedactedFactory(), + stickerFactory = TimelineItemContentStickerFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation() + ), + pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()), + utdFactory = TimelineItemContentUTDFactory(), + roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), + profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), + stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), + failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), + failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), + sessionId = matrixClient.sessionId, +) + internal fun TestScope.aTimelineItemsFactory( config: TimelineItemsFactoryConfig, ): TimelineItemsFactory { - val timelineEventFormatter = aTimelineEventFormatter() val matrixClient = FakeMatrixClient() return TimelineItemsFactory( dispatchers = testCoroutineDispatchers(), eventItemFactoryCreator = object : TimelineItemEventFactory.Creator { override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory { return TimelineItemEventFactory( - contentFactory = TimelineItemContentFactory( - messageFactory = TimelineItemContentMessageFactory( - fileSizeFormatter = FakeFileSizeFormatter(), - fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), - htmlConverterProvider = FakeHtmlConverterProvider(), - permalinkParser = FakePermalinkParser(), - textPillificationHelper = FakeTextPillificationHelper(), - ), - redactedMessageFactory = TimelineItemContentRedactedFactory(), - stickerFactory = TimelineItemContentStickerFactory( - fileSizeFormatter = FakeFileSizeFormatter(), - fileExtensionExtractor = FileExtensionExtractorWithoutValidation() - ), - pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()), - utdFactory = TimelineItemContentUTDFactory(), - roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), - profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), - stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), - failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), - failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), - sessionId = matrixClient.sessionId, - ), + contentFactory = aTimelineItemContentFactory(matrixClient = matrixClient), matrixClient = matrixClient, dateFormatter = FakeDateFormatter(), permalinkParser = FakePermalinkParser(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt new file mode 100644 index 0000000000..9f8d210a16 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt @@ -0,0 +1,74 @@ +/* + * 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.threads + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.fixtures.aTimelineItemContentFactory +import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter +import io.element.android.features.messages.impl.threads.list.ThreadsListEvents +import io.element.android.features.messages.impl.threads.list.ThreadsListPresenter +import io.element.android.features.messages.impl.threads.list.aThreadListItem +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ThreadsListPresenterTest { + @Test + fun `present - initial state`() = runTest { + createThreadsListPresenter().test { + awaitItem().run { + assertThat(threads).isEmpty() + assertThat(roomId).isEqualTo(A_ROOM_ID) + assertThat(roomName).isEqualTo(A_ROOM_NAME) + assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + } + } + + @Test + fun `present - paginate`() = runTest { + val paginateRecorder = lambdaRecorder> { Result.success(Unit) } + val threadsListService = FakeThreadsListService(paginate = paginateRecorder) + val room = FakeJoinedRoom(threadsListService = threadsListService) + createThreadsListPresenter(room).test { + val initialItem = awaitItem() + + // Pagination is automatically triggered on start, so we should have one call to paginate already + paginateRecorder.assertions().isCalledOnce() + + initialItem.eventSink(ThreadsListEvents.Paginate) + + // Simulate a pagination result + threadsListService.emit(listOf(aThreadListItem())) + + // We should have a second call to paginate after the event is sent + paginateRecorder.assertions().isCalledExactly(2) + + // And we receive the new items + assertThat(awaitItem().threads).isNotEmpty() + } + } + + private fun createThreadsListPresenter( + room: FakeJoinedRoom = FakeJoinedRoom(), + ): ThreadsListPresenter { + return ThreadsListPresenter( + room = room, + timelineItemContentFactory = aTimelineItemContentFactory(), + messageSummaryFormatter = FakeMessageSummaryFormatter(), + dateFormatter = FakeDateFormatter(), + ) + } +} 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 53d5a7c281..b1e3356fc3 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 @@ -13,10 +13,12 @@ import androidx.compose.ui.unit.dp enum class AvatarSize(val dp: Dp) { CurrentUserTopBar(32.dp), + CurrentRoomTopBar(32.dp), IncomingCall(140.dp), RoomDetailsHeader(96.dp), RoomListItem(52.dp), + ThreadsListItem(52.dp), SpaceListItem(52.dp), diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 5b65a32f61..eaa32e8adc 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -155,4 +155,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + RoomThreadList( + key = "feature.room_thread_list", + title = "Add a list of threads in a room", + description = "Add a new screen with a list of threads in a room.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index 808f37c7c9..32a6f2e409 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver @@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom { */ val liveTimeline: Timeline + val threadsListService: ThreadsListService + /** * Create a new timeline. * @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.kt new file mode 100644 index 0000000000..8282caafd1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.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.libraries.matrix.api.room.threads + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails + +@Immutable +data class ThreadListItem( + val rootEvent: ThreadListItemEvent, + val latestEvent: ThreadListItemEvent?, + val numberOfReplies: Long, +) { + val threadId = rootEvent.eventId.toThreadId() +} + +@Immutable +data class ThreadListItemEvent( + val eventId: EventId, + val senderId: UserId, + val senderProfile: ProfileDetails, + val isOwn: Boolean, + val content: EventContent?, + val timestamp: Long, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt new file mode 100644 index 0000000000..0716ca7c11 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt @@ -0,0 +1,16 @@ +/* + * 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.room.threads + +sealed interface ThreadListPaginationStatus { + data class Idle( + val hasMoreToLoad: Boolean, + ) : ThreadListPaginationStatus + + data object Loading : ThreadListPaginationStatus +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt new file mode 100644 index 0000000000..7f819c540c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.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.api.room.threads + +import kotlinx.coroutines.flow.Flow + +interface ThreadsListService { + fun subscribeToItemUpdates(): Flow> + fun subscribeToPaginationUpdates(): Flow + suspend fun paginate(): Result + suspend fun reset(): Result + fun destroy() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 73e76f73a8..e6287d0d16 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver @@ -44,8 +45,10 @@ import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher +import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.RustTimeline +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver @@ -145,6 +148,12 @@ class JoinedRustRoom( override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) + override val threadsListService: ThreadsListService = RustThreadsListService( + inner = innerRoom.threadListService(), + contentMapper = TimelineEventContentMapper(), + roomCoroutineScope = roomCoroutineScope, + ) + override val syncUpdateFlow = flow { var counter = 0L liveTimeline.onSyncedEventReceived.collect { @@ -528,6 +537,7 @@ class JoinedRustRoom( override fun destroy() { baseRoom.destroy() liveInnerTimeline.destroy() + threadsListService.destroy() Timber.d("Room $roomId destroyed") } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt new file mode 100644 index 0000000000..a74c5bc378 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -0,0 +1,157 @@ +/* + * 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.room.threads + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.map +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import org.matrix.rustcomponents.sdk.ThreadListEntriesListener +import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener +import org.matrix.rustcomponents.sdk.ThreadListUpdate +import uniffi.matrix_sdk_ui.ThreadListPaginationState +import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService + +class RustThreadsListService( + private val inner: InnerThreadListService, + private val roomCoroutineScope: CoroutineScope, + private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), +) : ThreadsListService { + private var itemSubscriptionJob: Job? = null + + private val items = MutableStateFlow>(emptyList()) + + override fun subscribeToItemUpdates(): Flow> { + if (itemSubscriptionJob?.isActive != true) { + itemSubscriptionJob = doSubscribeToItemUpdates() + } + + return items + } + + private fun doSubscribeToItemUpdates(): Job { + val updatesFlow = mxCallbackFlow { + inner.subscribeToItemsUpdates(object : ThreadListEntriesListener { + override fun onUpdate(diff: List) { + trySend(diff) + } + }) + } + + return updatesFlow + .onStart { items.value = inner.items().map { it.map(contentMapper) } } + .onEach { diff -> + val updated = items.value.toMutableList() + updated.apply(diff, contentMapper) + items.value = updated + } + .launchIn(roomCoroutineScope) + } + + override fun subscribeToPaginationUpdates(): Flow { + return mxCallbackFlow { + inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener { + override fun onUpdate(state: ThreadListPaginationState) { + trySend(state.map()) + } + }).also { + // Send the initial state + trySend(inner.paginationState().map()) + } + } + } + + override suspend fun paginate(): Result = runCatchingExceptions { + inner.paginate() + } + + override suspend fun reset(): Result = runCatchingExceptions { + inner.reset() + } + + override fun destroy() { + itemSubscriptionJob?.cancel() + inner.destroy() + } +} + +private fun MutableList.apply( + diff: List, + contentMapper: TimelineEventContentMapper +) { + for (diffItem in diff) { + when (diffItem) { + is ThreadListUpdate.Append -> { + val newItems = diffItem.values.map { it.map(contentMapper) } + addAll(newItems) + } + ThreadListUpdate.Clear -> clear() + is ThreadListUpdate.Insert -> { + add(diffItem.index.toInt(), diffItem.value.map(contentMapper)) + } + ThreadListUpdate.PopBack -> { + removeAt(lastIndex) + } + ThreadListUpdate.PopFront -> { + removeAt(0) + } + is ThreadListUpdate.PushBack -> { + add(diffItem.value.map(contentMapper)) + } + is ThreadListUpdate.PushFront -> { + add(0, diffItem.value.map(contentMapper)) + } + is ThreadListUpdate.Remove -> { + removeAt(diffItem.index.toInt()) + } + is ThreadListUpdate.Reset -> { + clear() + addAll(diffItem.values.map { it.map(contentMapper) }) + } + is ThreadListUpdate.Set -> { + set(diffItem.index.toInt(), diffItem.value.map(contentMapper)) + } + is ThreadListUpdate.Truncate -> { + subList(diffItem.length.toInt(), size).clear() + } + } + } +} + +fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem( + rootEvent = rootEvent.map(contentMapper), + latestEvent = latestEvent?.map(contentMapper), + numberOfReplies = numReplies.toLong(), +) + +fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent( + eventId = EventId(eventId), + senderId = UserId(sender), + isOwn = isOwn, + senderProfile = senderProfile.map(), + content = content?.let(contentMapper::map), + timestamp = timestamp.toLong(), +) + +fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) { + is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached) + ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt new file mode 100644 index 0000000000..009e6a3348 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt @@ -0,0 +1,58 @@ +/* + * 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.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.ThreadListEntriesListener +import org.matrix.rustcomponents.sdk.ThreadListItem +import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener +import org.matrix.rustcomponents.sdk.ThreadListService +import org.matrix.rustcomponents.sdk.ThreadListUpdate +import uniffi.matrix_sdk_ui.ThreadListPaginationState + +class FakeFfiThreadListService( + private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() }, + private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() }, + private val items: () -> List = { emptyList() }, + private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) }, + private val paginate: suspend () -> Unit = {}, + private val reset: suspend () -> Unit = {}, + private val destroy: () -> Unit = {}, +) : ThreadListService(NoHandle) { + private var itemsListener: ThreadListEntriesListener? = null + private var paginationStateListener: ThreadListPaginationStateListener? = null + + override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle { + itemsListener = listener + return subscribeToItemsUpdates.invoke(listener) + } + + override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle { + paginationStateListener = listener + return subscribeToPaginationStateUpdates.invoke(listener) + } + + override fun items(): List = items.invoke() + + override fun paginationState(): ThreadListPaginationState = paginationState.invoke() + + override suspend fun paginate() = paginate.invoke() + + override suspend fun reset() = reset.invoke() + + override fun destroy() = destroy.invoke() + + fun emitUpdates(updates: List) { + itemsListener?.onUpdate(updates) + } + + fun emitPaginationState(state: ThreadListPaginationState) { + paginationStateListener?.onUpdate(state) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt new file mode 100644 index 0000000000..0fc9ad0603 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -0,0 +1,143 @@ +/* + * 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.room.threads + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_TIMESTAMP +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.ThreadListEntriesListener +import org.matrix.rustcomponents.sdk.ThreadListItem +import org.matrix.rustcomponents.sdk.ThreadListItemEvent +import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener +import org.matrix.rustcomponents.sdk.ThreadListUpdate +import uniffi.matrix_sdk_ui.ThreadListPaginationState + +@OptIn(ExperimentalCoroutinesApi::class) +class RustThreadsListServiceTest { + @Test + fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest { + val subscribeToItemsUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } + val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder) + val service = createThreadsListService(inner = inner) + + service.subscribeToItemUpdates().test { + assertThat(awaitItem()).isEmpty() + + runCurrent() + subscribeToItemsUpdatesRecorder.assertions().isCalledOnce() + + inner.emitUpdates(listOf(aRustThreadListUpdate())) + + assertThat(awaitItem()).isNotEmpty() + } + } + + @Suppress("UnusedFlow") + @Test + fun `subscribing to item updates twice only calls the FFI method once`() = runTest { + val subscribeToItemsUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } + val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder) + val service = createThreadsListService(inner = inner) + + service.subscribeToItemUpdates() + service.subscribeToItemUpdates() + + runCurrent() + + subscribeToItemsUpdatesRecorder.assertions().isCalledOnce() + } + + @Test + fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest { + val subscribeToPaginationUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } + val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder) + val service = createThreadsListService(inner = inner) + + service.subscribeToPaginationUpdates().test { + assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)) + + runCurrent() + subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce() + + inner.emitPaginationState(ThreadListPaginationState.Loading) + + assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading) + } + } + + @Test + fun `paginate calls the FFI method`() = runTest { + val paginateRecorder = lambdaRecorder {} + val inner = FakeFfiThreadListService(paginate = paginateRecorder) + val service = createThreadsListService(inner = inner) + + service.paginate() + + paginateRecorder.assertions().isCalledOnce() + } + + @Test + fun `reset calls the FFI method`() = runTest { + val resetRecorder = lambdaRecorder {} + val inner = FakeFfiThreadListService(reset = resetRecorder) + val service = createThreadsListService(inner = inner) + + service.reset() + + resetRecorder.assertions().isCalledOnce() + } + + @Test + fun `destroy calls the FFI method`() = runTest { + val destroyRecorder = lambdaRecorder {} + val inner = FakeFfiThreadListService(destroy = destroyRecorder) + val service = createThreadsListService(inner = inner) + + service.destroy() + + destroyRecorder.assertions().isCalledOnce() + } + + private fun TestScope.createThreadsListService( + inner: FakeFfiThreadListService = FakeFfiThreadListService(), + ) = RustThreadsListService( + inner = inner, + roomCoroutineScope = backgroundScope, + ) + + private fun aRustThreadListUpdate() = ThreadListUpdate.Append( + values = listOf( + ThreadListItem( + rootEvent = ThreadListItemEvent( + eventId = AN_EVENT_ID.value, + timestamp = A_TIMESTAMP.toULong(), + sender = A_USER_ID.value, + senderProfile = ProfileDetails.Pending, + isOwn = true, + content = aRustTimelineItemContentMsgLike(), + ), + numReplies = 0u, + latestEvent = null, + ) + ), + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index a4580334e4..84497b38de 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -56,6 +57,7 @@ class FakeJoinedRoom( override val roomNotificationSettingsStateFlow: StateFlow = MutableStateFlow(RoomNotificationSettingsState.Unknown), override val knockRequestsFlow: Flow> = MutableStateFlow(emptyList()), + override val threadsListService: FakeThreadsListService = FakeThreadsListService(), private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), private var createTimelineResult: (CreateTimelineParams) -> Result = { lambdaError() }, private val editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() }, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt new file mode 100644 index 0000000000..a1e719ffb2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt @@ -0,0 +1,48 @@ +/* + * 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.room.threads + +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeThreadsListService( + private val items: MutableStateFlow> = MutableStateFlow(emptyList()), + private val paginationStatus: MutableStateFlow = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)), + private val subscribeToItemUpdates: () -> Flow> = { items }, + private val subscribeToPaginationUpdates: () -> Flow = { paginationStatus }, + private val paginate: suspend () -> Result = { Result.success(Unit) }, + private val reset: suspend () -> Result = { Result.success(Unit) }, + private val destroy: () -> Unit = {}, +) : ThreadsListService { + override fun subscribeToItemUpdates(): Flow> { + return subscribeToItemUpdates.invoke() + } + + override fun subscribeToPaginationUpdates(): Flow { + return subscribeToPaginationUpdates.invoke() + } + + override suspend fun paginate(): Result { + return paginate.invoke() + } + + override suspend fun reset(): Result { + return reset.invoke() + } + + override fun destroy() { + return destroy.invoke() + } + + suspend fun emit(items: List) { + this.items.emit(items) + } +} diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png new file mode 100644 index 0000000000..ac6836af2b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c523f3a502600b837c07ecd5804831da2d9aba5a74886b7001affbb90169112a +size 12455 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png new file mode 100644 index 0000000000..05f2571669 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:528c2f3183a153b9129606806fc457265819ececd71cf5021d1d843970c0b774 +size 12366 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png new file mode 100644 index 0000000000..adeb4f7c4d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c02999b2d0eba92f1e9b1f5fac52bcff303401f177459d1a439b7db906e5c2a +size 64622 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png new file mode 100644 index 0000000000..d89f062c11 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7f6b64bfe0b47546a009efe23060ba091adbad32288ce546cb3b030d7a9ec88 +size 66177 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png index 8c023c5e17..b2c131b124 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3136c95bc9134eba4cfdfe8d552473a54287c356bd3895291b9cd4ec11969d9c -size 52706 +oid sha256:e26887bd81e10726414e1833029b4b51e22e534684a230777ca50f86024af994 +size 56430 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png index 3b3133cf67..41b8b16bcc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6770e720477c2547f593c626cfe3bdafb9b7c78d0b66e910fb9eb1163730045f -size 51707 +oid sha256:ce44cf850169736008a3f3fc21a2be4fb044badfae631b0284ce379b325879df +size 55533 From 66513bc905e9517bad8ace365b233b8c83527dd1 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 15 Apr 2026 14:29:41 +0200 Subject: [PATCH 31/53] Take into account homeserver capabilities (#6507) * Take into account homeserver capabilities: add `HomeserverCapabilitiesProvider` to check if the HS allows changing the user's display name or avatar. Also, modify the edit user profile screen to reflect these values. * Add `/myavatar` command. Filter both `/nick` and `/myavatar` commands based on the homeserver capabilities. * Update screenshots * Assume the use can change their display name and avatar url if the capabilities check fails: if they try to change those, the HS will return an error anyway. * Disable also `/myroomname` and `/myroomavatar` based on the HS capabilities. --------- Co-authored-by: ElementBot --- .../appnav/loggedin/LoggedInPresenter.kt | 11 ++++ .../appnav/loggedin/LoggedInPresenterTest.kt | 28 ++++++++ .../editprofile/EditUserProfilePresenter.kt | 11 ++++ .../user/editprofile/EditUserProfileState.kt | 2 + .../EditUserProfileStateProvider.kt | 5 ++ .../user/editprofile/EditUserProfileView.kt | 2 + .../api/HomeserverCapabilitiesProvider.kt | 30 +++++++++ .../libraries/matrix/api/MatrixClient.kt | 2 + .../RustHomeserverCapabilitiesProvider.kt | 28 ++++++++ .../libraries/matrix/impl/RustMatrixClient.kt | 5 ++ .../matrix/impl/di/SessionMatrixModule.kt | 6 ++ .../RustHomeserverCapabilitiesProviderTest.kt | 64 +++++++++++++++++++ .../impl/fixtures/fakes/FakeFfiClient.kt | 6 ++ .../fakes/FakeFfiHomeserverCapabilities.kt | 33 ++++++++++ .../FakeHomeserverCapabilitiesProvider.kt | 20 ++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 6 ++ .../slashcommands/api/SlashCommand.kt | 1 + .../libraries/slashcommands/impl/Command.kt | 9 +++ .../slashcommands/impl/CommandExecutor.kt | 5 ++ .../slashcommands/impl/CommandParser.kt | 12 ++++ .../impl/DefaultSlashCommandService.kt | 52 +++++++++++---- .../impl/src/main/res/values/temporary.xml | 1 + .../slashcommands/impl/CommandParserTest.kt | 9 ++- .../impl/DefaultSlashCommandServiceTest.kt | 23 +++++++ ...itprofile_EditUserProfileView_Day_3_en.png | 3 + ...profile_EditUserProfileView_Night_3_en.png | 3 + 26 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 757dd73395..8dc2de5e4e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -56,6 +58,7 @@ class LoggedInPresenter( private val analyticsService: AnalyticsService, private val encryptionService: EncryptionService, private val buildMeta: BuildMeta, + private val networkMonitor: NetworkMonitor, ) : Presenter { @Composable override fun present(): LoggedInState { @@ -107,6 +110,14 @@ class LoggedInPresenter( }.launchIn(this) } + val networkConnectivity by networkMonitor.connectivity.collectAsState() + LaunchedEffect(networkConnectivity) { + if (networkConnectivity == NetworkStatus.Connected) { + // Refresh homeserver capabilities when the network is back + matrixClient.homeserverCapabilities().refresh() + } + } + fun handleEvent(event: LoggedInEvents) { when (event) { is LoggedInEvents.CloseErrorDialog -> { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index f1759eab3e..d147a4ed68 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -14,6 +14,8 @@ import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId @@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService @@ -109,6 +112,7 @@ class LoggedInPresenterTest { val verificationService = FakeSessionVerificationService() val encryptionService = FakeEncryptionService() val buildMeta = aBuildMeta() + val networkMonitor = FakeNetworkMonitor() LoggedInPresenter( matrixClient = FakeMatrixClient( roomListService = roomListService, @@ -122,6 +126,7 @@ class LoggedInPresenterTest { analyticsService = analyticsService, encryptionService = encryptionService, buildMeta = buildMeta, + networkMonitor = networkMonitor, ).test { encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) @@ -319,6 +324,27 @@ class LoggedInPresenterTest { } } + @Test + fun `present - refreshes homeserver capabilities when network is back`() = runTest { + val refreshLambda = lambdaRecorder> { Result.success(Unit) } + val matrixClient = FakeMatrixClient( + homeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(refresh = refreshLambda), + accountManagementUrlResult = { Result.success(null) }, + ) + val networkMonitor = FakeNetworkMonitor() + createLoggedInPresenter( + matrixClient = matrixClient, + networkMonitor = networkMonitor, + ).test { + awaitItem() + networkMonitor.connectivity.value = NetworkStatus.Connected + + advanceUntilIdle() + + refreshLambda.assertions().isCalledOnce() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() @@ -334,6 +360,7 @@ class LoggedInPresenterTest { accountManagementUrlResult = { Result.success(null) }, ), buildMeta: BuildMeta = aBuildMeta(), + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), ): LoggedInPresenter { return LoggedInPresenter( matrixClient = matrixClient, @@ -343,6 +370,7 @@ class LoggedInPresenterTest { analyticsService = analyticsService, encryptionService = encryptionService, buildMeta = buildMeta, + networkMonitor = networkMonitor, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index bddae2fffb..1ab69f6007 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf 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.saveable.rememberSaveable @@ -103,6 +104,14 @@ class EditUserProfilePresenter( } } + val homeserverCapabilities = matrixClient.homeserverCapabilities() + val canChangeDisplayName = produceState(true) { + value = homeserverCapabilities.canChangeDisplayName().getOrDefault(true) + } + val canChangeAvatar = produceState(true) { + value = homeserverCapabilities.canChangeAvatarUrl().getOrDefault(true) + } + val saveAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() @@ -169,6 +178,8 @@ class EditUserProfilePresenter( saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading, saveAction = saveAction.value, cameraPermissionState = cameraPermissionState, + canChangeDisplayName = canChangeDisplayName.value, + canChangeAvatarUrl = canChangeAvatar.value, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt index a638ed8378..a40f1710e2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt @@ -22,5 +22,7 @@ data class EditUserProfileState( val saveButtonEnabled: Boolean, val saveAction: AsyncAction, val cameraPermissionState: PermissionsState, + val canChangeDisplayName: Boolean, + val canChangeAvatarUrl: Boolean, val eventSink: (EditUserProfileEvent) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt index ca9571aea5..13f69a7e1e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt @@ -22,6 +22,7 @@ open class EditUserProfileStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + canChangeDisplayName: Boolean = true, + canChangeAvatarUrl: Boolean = true, eventSink: (EditUserProfileEvent) -> Unit = {}, ) = EditUserProfileState( userId = userId, @@ -42,5 +45,7 @@ fun aEditUserProfileState( saveButtonEnabled = saveButtonEnabled, saveAction = saveAction, cameraPermissionState = cameraPermissionState, + canChangeDisplayName = canChangeDisplayName, + canChangeAvatarUrl = canChangeAvatarUrl, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 774dcedae0..d4571d7be5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -120,6 +120,7 @@ fun EditUserProfileView( state = avatarPickerState, onClick = ::onAvatarClick, modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = state.canChangeAvatarUrl, ) Spacer(modifier = Modifier.height(16.dp)) Text( @@ -134,6 +135,7 @@ fun EditUserProfileView( value = state.displayName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, + enabled = state.canChangeDisplayName, onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) }, ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt new file mode 100644 index 0000000000..e914656f5f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +/** + * Provides information about the capabilities of the homeserver. + * + * Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation + */ +interface HomeserverCapabilitiesProvider { + /** + * Manually refresh the capabilities of the homeserver performing a network request. + */ + suspend fun refresh(): Result + + /** + * Indicates whether the homeserver allows the user to change their display name. + */ + suspend fun canChangeDisplayName(): Result + + /** + * Indicates whether the homeserver allows the user to change their avatar URL. + */ + suspend fun canChangeAvatarUrl(): Result +} 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 773dbaaa07..35fd7e8551 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 @@ -223,6 +223,8 @@ interface MatrixClient { * Resets the cached client `well-known` config by the SDK. */ suspend fun resetWellKnownConfig(): Result + + fun homeserverCapabilities(): HomeserverCapabilitiesProvider } /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt new file mode 100644 index 0000000000..d82e389aa7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt @@ -0,0 +1,28 @@ +/* + * 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.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider +import org.matrix.rustcomponents.sdk.HomeserverCapabilities + +class RustHomeserverCapabilitiesProvider( + private val homeserverCapabilities: HomeserverCapabilities, +) : HomeserverCapabilitiesProvider { + override suspend fun refresh(): Result = runCatchingExceptions { + homeserverCapabilities.refresh() + } + + override suspend fun canChangeDisplayName(): Result = runCatchingExceptions { + homeserverCapabilities.canChangeDisplayname() + } + + override suspend fun canChangeAvatarUrl(): Result = runCatchingExceptions { + homeserverCapabilities.canChangeAvatar() + } +} 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 1c87e73ba2..bb6806b5d4 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 @@ -17,6 +17,7 @@ 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.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId @@ -835,6 +836,10 @@ class RustMatrixClient( val request = PerformDatabaseVacuumRequestBuilder(sessionId) sessionCoroutineScope.launch { workManagerScheduler.submit(request) } } + + override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { + return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) + } } private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels( 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 6ca7d27a8f..bef24003b9 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,6 +13,7 @@ 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.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -90,4 +91,9 @@ object SessionMatrixModule { fun providesSpaceService(matrixClient: MatrixClient): SpaceService { return matrixClient.spaceService } + + @Provides + fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider { + return matrixClient.homeserverCapabilities() + } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt new file mode 100644 index 0000000000..8d3377d698 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt @@ -0,0 +1,64 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverCapabilities +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RustHomeserverCapabilitiesProviderTest { + @Test + fun `refresh calls client refresh`() = runTest { + val refreshLambda = lambdaRecorder {} + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda), + ) + assertThat(provider.refresh().isSuccess).isTrue() + refreshLambda.assertions().isCalledOnce() + } + + @Test + fun `refresh fails when client refresh does`() = runTest { + val refreshLambda = lambdaRecorder { throw IllegalStateException("Failed to refresh capabilities") } + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda), + ) + assertThat(provider.refresh().isFailure).isTrue() + refreshLambda.assertions().isCalledOnce() + } + + @Test + fun `canChangeDisplayName returns expected value`() = runTest { + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }), + ) + assertThat(provider.canChangeDisplayName().getOrNull()).isTrue() + } + + @Test + fun `canChangeAvatarUrl returns expected value`() = runTest { + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }), + ) + assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue() + } + + @Test + fun `canChangeDisplayName returns failure when client throws`() = runTest { + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }), + ) + assert(provider.canChangeDisplayName().isFailure) + } + + private fun createCapabilitiesProvider( + capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(), + ) = RustHomeserverCapabilitiesProvider(capabilities) +} 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 2aec38fcde..57ffcddb37 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 @@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.NoHandle @@ -50,6 +51,7 @@ class FakeFfiClient( private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, + private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -103,5 +105,9 @@ class FakeFfiClient( return createRoomResult(request) } + override fun homeserverCapabilities(): HomeserverCapabilities { + return homeserverCapabilities + } + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt new file mode 100644 index 0000000000..4c60cbbb49 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.tests.testutils.lambda.lambdaError +import org.matrix.rustcomponents.sdk.ExtendedProfileFields +import org.matrix.rustcomponents.sdk.HomeserverCapabilities +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiHomeserverCapabilities( + private val refresh: () -> Unit = { lambdaError() }, + private val canChangeDisplayName: () -> Boolean = { lambdaError() }, + private val canChangeAvatar: () -> Boolean = { lambdaError() }, + private val canChangePassword: () -> Boolean = { lambdaError() }, + private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() }, + private val canGetLoginToken: () -> Boolean = { lambdaError() }, + private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() }, + private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() }, +) : HomeserverCapabilities(NoHandle) { + override suspend fun refresh() = refresh.invoke() + override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke() + override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke() + override suspend fun canChangePassword(): Boolean = canChangePassword.invoke() + override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke() + override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke() + override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke() + override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt new file mode 100644 index 0000000000..c098388c89 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt @@ -0,0 +1,20 @@ +/* + * 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.HomeserverCapabilitiesProvider + +class FakeHomeserverCapabilitiesProvider( + private val refresh: () -> Result = { Result.success(Unit) }, + private val canChangeDisplayName: () -> Result = { Result.success(true) }, + private val canChangeAvatarUrl: () -> Result = { Result.success(true) }, +) : HomeserverCapabilitiesProvider { + override suspend fun refresh(): Result = refresh.invoke() + override suspend fun canChangeDisplayName(): Result = canChangeDisplayName.invoke() + override suspend fun canChangeAvatarUrl(): Result = canChangeAvatarUrl.invoke() +} 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 56527574d7..742af160ae 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 @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.test +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId @@ -84,6 +85,7 @@ class FakeMatrixClient( override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: (RoomAlias) -> Result> = { Result.success( @@ -384,4 +386,8 @@ class FakeMatrixClient( override suspend fun resetWellKnownConfig(): Result { return resetWellKnownConfigLambda() } + + override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { + return homeserverCapabilitiesProvider + } } diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt index 770543e548..50d5a5ce32 100644 --- a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt @@ -51,6 +51,7 @@ sealed interface SlashCommand { data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin + data class ChangeAvatar(val url: String) : SlashCommandAdmin data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin data class SendSpoiler(val message: String) : SlashCommandSendMessage data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt index 0b7b58a15f..0d9e1e8c72 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt @@ -120,6 +120,15 @@ enum class Command( isDevCommand = true, isSupported = false, ), + CHANGE_AVATAR( + command = "/myavatar", + parameters = "", + description = R.string.slash_command_description_avatar, + isAllowedInThread = false, + // Dev command since user has to know the mxc url + isDevCommand = true, + isSupported = false, + ), CHANGE_AVATAR_FOR_ROOM( command = "/myroomavatar", parameters = "", diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt index 0acd3af6f8..ad252cb224 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt @@ -44,6 +44,7 @@ class CommandExecutor( ): Result { return when (slashCommand) { is SlashCommand.BanUser -> banUser(slashCommand) + is SlashCommand.ChangeAvatar -> changeAvatar() is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom() is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand) is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom() @@ -178,6 +179,10 @@ class CommandExecutor( return matrixClient.setDisplayName(slashCommand.displayName) } + private fun changeAvatar(): Result { + return Result.failure(Exception("Not yet implemented")) + } + private fun changeAvatarForRoom(): Result { return Result.failure(Exception("Not yet implemented")) } diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt index 85a045f50c..55125af20b 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt @@ -107,6 +107,18 @@ class CommandParser( syntaxError(Command.ROOM_AVATAR) } } + Command.CHANGE_AVATAR.matches(slashCommand) -> { + if (messageParts.size == 2) { + val url = messageParts[1] + if (url.isMxcUrl()) { + SlashCommand.ChangeAvatar(url) + } else { + syntaxError(Command.CHANGE_AVATAR) + } + } else { + syntaxError(Command.CHANGE_AVATAR) + } + } Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> { if (messageParts.size == 2) { val url = messageParts[1] diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt index ba2786c944..6cd8688cad 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt @@ -11,6 +11,7 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.RoomScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.slashcommands.api.SlashCommand @@ -18,6 +19,8 @@ import io.element.android.libraries.slashcommands.api.SlashCommandService import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds @ContributesBinding(RoomScope::class) class DefaultSlashCommandService( @@ -26,6 +29,7 @@ class DefaultSlashCommandService( private val stringProvider: StringProvider, private val appPreferencesStore: AppPreferencesStore, private val featureFlagService: FeatureFlagService, + private val capabilitiesProvider: HomeserverCapabilitiesProvider, ) : SlashCommandService { override suspend fun getSuggestions( text: String, @@ -33,19 +37,41 @@ class DefaultSlashCommandService( ): List { if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList() val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first() - return Command.entries.filter { - it.startsWith(text) - }.filter { - !isInThread || it.isAllowedInThread - }.filter { - !it.isDevCommand || isDeveloperModeEnabled - }.map { - SlashCommandSuggestion( - command = it.command, - parameters = it.parameters, - description = stringProvider.getString(it.description), - ) - } + return Command.entries + .asSequence() + .filter { it.startsWith(text) } + .filter { !isInThread || it.isAllowedInThread } + .filter { !it.isDevCommand || isDeveloperModeEnabled } + // Don't include the change display name commands if the user can't change their display name + .run { + val canUserChangeDisplayName = withTimeoutOrNull(5.seconds) { + capabilitiesProvider.canChangeDisplayName().getOrNull() + } ?: false + if (!canUserChangeDisplayName) { + filterNot { it == Command.CHANGE_DISPLAY_NAME || it == Command.CHANGE_DISPLAY_NAME_FOR_ROOM } + } else { + this + } + } + // Don't include the change avatar commands if the user can't change their avatar url + .run { + val canUserChangeAvatar = withTimeoutOrNull(5.seconds) { + capabilitiesProvider.canChangeAvatarUrl().getOrNull() + } ?: false + if (!canUserChangeAvatar) { + filterNot { it == Command.CHANGE_AVATAR || it == Command.CHANGE_AVATAR_FOR_ROOM } + } else { + this + } + } + .map { + SlashCommandSuggestion( + command = it.command, + parameters = it.parameters, + description = stringProvider.getString(it.description), + ) + } + .toList() } override suspend fun parse( diff --git a/libraries/slashcommands/impl/src/main/res/values/temporary.xml b/libraries/slashcommands/impl/src/main/res/values/temporary.xml index 0a8f2a0034..26232ea9b3 100644 --- a/libraries/slashcommands/impl/src/main/res/values/temporary.xml +++ b/libraries/slashcommands/impl/src/main/res/values/temporary.xml @@ -26,6 +26,7 @@ Set the room topic Removes user with given id from this room Changes your display nickname + Changes your profile picture in all rooms Sends the given message with confetti Sends the given message with snowfall Sends a message as plain text, without interpreting it as markdown diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt index 0887847a40..f5a6f54dfd 100644 --- a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt @@ -78,12 +78,19 @@ class CommandParserTest { } @Test - fun parseSlashCommandPlainAndNick() = runTest { + fun parseSlashCommandPlain() = runTest { test("/plain hello", SlashCommand.SendPlainText("hello")) test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain ")) + } + @Test + fun parseSlashCommandNickAndMyAvatar() = runTest { test("/nick John", SlashCommand.ChangeDisplayName("John")) test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick ")) + + test("/myavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatar("mxc://matrix.org/abc")) + test("/myavatar http://notmxc", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar ")) + test("/myavatar", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar ")) } @Test diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt index 243f25666c..cee4d17b21 100644 --- a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.timeline.MsgType +import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom @@ -116,6 +117,26 @@ class DefaultSlashCommandServiceTest { sendMessage.assertions().isCalledOnce() } + @Test + fun `canChangeDisplayName is respected in suggestions`() = runTest { + var result = false + val capabilitiesProvider = FakeHomeserverCapabilitiesProvider( + canChangeDisplayName = { Result.success(result) }, + ) + val sut = createDefaultSlashCommandService(capabilitiesProvider = capabilitiesProvider) + + // Initially, with a disabled capability, the change display name command should not be in the suggestions + var changeNameCommand = sut.getSuggestions("", isInThread = false) + .find { it.command == Command.CHANGE_DISPLAY_NAME.command } + assertThat(changeNameCommand).isNull() + + // When the capability is true, the command should be included in the suggestions + result = true + changeNameCommand = sut.getSuggestions("", isInThread = false) + .find { it.command == Command.CHANGE_DISPLAY_NAME.command } + assertThat(changeNameCommand).isNotNull() + } + @Test fun `proceedAdmin delegates to commandExecutor`() = runTest { val leaveRoomLambda = lambdaRecorder> { @@ -155,11 +176,13 @@ class DefaultSlashCommandServiceTest { commandExecutor: CommandExecutor = createCommandExecutor( stringProvider = stringProvider, ), + capabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), ) = DefaultSlashCommandService( commandParser = commandParser, commandExecutor = commandExecutor, stringProvider = stringProvider, appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService, + capabilitiesProvider = capabilitiesProvider, ) } diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png new file mode 100644 index 0000000000..8517440a90 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4dbcac95e6fd72ee2eb683a4f25d49f4d181be9b1386ec01670fb078bc46a52 +size 18966 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png new file mode 100644 index 0000000000..75ce9869d4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:901c40d524de6de6fd70cbfcfc8b1d86cc896734254452535a07830587adafcf +size 18987 From 8503d1c5488abf98a1b0f2465b5abc6a2d2bccd1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Apr 2026 14:45:38 +0200 Subject: [PATCH 32/53] Fix tests compilation --- features/login/impl/build.gradle.kts | 1 + .../login/impl/DefaultLoginEntryPointTest.kt | 2 ++ .../screens/onboarding/OnboardingViewTest.kt | 23 +++++++++++++ features/preferences/test/build.gradle.kts | 19 +++++++++++ .../test/FakePreferencesEntryPoint.kt | 32 +++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 features/preferences/test/build.gradle.kts create mode 100644 features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakePreferencesEntryPoint.kt diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 83dd4cf7bd..12af922cbe 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) + testImplementation(projects.features.preferences.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oidc.test) 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 1d24d775be..86a629270f 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,6 +16,7 @@ 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.classic.FakeElementClassicConnection +import io.element.android.features.preferences.test.FakePreferencesEntryPoint import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -41,6 +42,7 @@ class DefaultLoginEntryPointTest { oidcActionFlow = FakeOidcActionFlow(), appCoroutineScope = backgroundScope, elementClassicConnection = FakeElementClassicConnection(), + preferencesEntryPoint = FakePreferencesEntryPoint(), ) } val callback = object : LoginEntryPoint.Callback { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt index c3d1ff6015..ad09445075 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues @@ -46,11 +47,15 @@ class OnboardingViewTest { rule.setOnboardingView( state = anOnBoardingState( canCreateAccount = true, + showDeveloperSettings = false, eventSink = eventSink, ), onCreateAccount = callback, ) rule.clickOn(R.string.screen_onboarding_sign_up) + // Developer settings should not be shown + val developerSettingsText = rule.activity.getString(CommonStrings.common_developer_options) + rule.onNodeWithContentDescription(developerSettingsText).assertDoesNotExist() } } @@ -172,6 +177,22 @@ class OnboardingViewTest { } } + @Test + fun `clicking on settings calls the developer settings callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + showDeveloperSettings = true, + eventSink = eventSink, + ), + onDeveloperSettingsClick = callback, + ) + val text = rule.activity.getString(CommonStrings.common_developer_options) + rule.onNodeWithContentDescription(text).performClick() + } + } + @Test fun `cannot report a problem when the feature is disabled`() { val eventSink = EventsRecorder(expectEvents = false) @@ -235,6 +256,7 @@ class OnboardingViewTest { private fun AndroidComposeTestRule.setOnboardingView( state: OnBoardingState, onBackClick: () -> Unit = EnsureNeverCalled(), + onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(), onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onCreateAccount: () -> Unit = EnsureNeverCalled(), @@ -248,6 +270,7 @@ class OnboardingViewTest { OnBoardingView( state = state, onBackClick = onBackClick, + onDeveloperSettingsClick = onDeveloperSettingsClick, onSignInWithQrCode = onSignInWithQrCode, onSignIn = onSignIn, onCreateAccount = onCreateAccount, diff --git a/features/preferences/test/build.gradle.kts b/features/preferences/test/build.gradle.kts new file mode 100644 index 0000000000..7e3da4a6e8 --- /dev/null +++ b/features/preferences/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.preferences.test" +} + +dependencies { + implementation(projects.features.preferences.api) + implementation(projects.tests.testutils) +} diff --git a/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakePreferencesEntryPoint.kt b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakePreferencesEntryPoint.kt new file mode 100644 index 0000000000..c57ed434fa --- /dev/null +++ b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakePreferencesEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * 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 com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePreferencesEntryPoint : PreferencesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: PreferencesEntryPoint.Params, + callback: PreferencesEntryPoint.Callback, + ): Node { + lambdaError() + } + + override fun createAppDeveloperSettingsNode( + parentNode: Node, + buildContext: BuildContext, + callback: PreferencesEntryPoint.DeveloperSettingsCallback, + ): Node { + lambdaError() + } +} From 0058de9bca31949ce3817ac6160d4e8cafa8fc24 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 15 Apr 2026 15:14:42 +0200 Subject: [PATCH 33/53] Add extra logs for timeline pagination (#6589) We found some possible rare issues with pagination these could help understand. --- .../matrix/impl/timeline/RustTimeline.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 8a184311de..7b398529a0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -130,6 +130,8 @@ class RustTimeline( Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent) ) + private val loggerTag = "Timeline($mode)" + init { when (mode) { is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers() @@ -177,10 +179,11 @@ class RustTimeline( } private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) { - when (direction) { + val result = when (direction) { Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update) Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update) } + Timber.tag(loggerTag).d("updatePaginationStatus $direction: $result") } // Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled. @@ -195,12 +198,13 @@ class RustTimeline( } }.onFailure { error -> if (error is TimelineException.CannotPaginate) { - Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}") + Timber.tag(loggerTag).d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}") } else { updatePaginationStatus(direction) { it.copy(isPaginating = false) } - Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}") + Timber.tag(loggerTag).e(error, "Error paginating $direction on room ${joinedRoom.roomId}") } }.onSuccess { hasReachedEnd -> + Timber.tag(loggerTag).d("Finished paginating $direction on room ${joinedRoom.roomId}, hasReachedEnd: $hasReachedEnd") updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } } } @@ -264,7 +268,7 @@ class RustTimeline( try { inner.fetchMembers() } catch (exception: Exception) { - Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}") + Timber.tag(loggerTag).e(exception, "Error fetching members for room ${joinedRoom.roomId}") } } @@ -370,7 +374,7 @@ class RustTimeline( formattedCaption: String?, inReplyToEventId: EventId?, ): Result { - Timber.d("Sending image ${file.path.hash()}") + Timber.tag(loggerTag).d("Sending image ${file.path.hash()}") return sendAttachment(listOfNotNull(file, thumbnailFile)) { inner.sendImage( params = UploadParameters( @@ -396,7 +400,7 @@ class RustTimeline( formattedCaption: String?, inReplyToEventId: EventId?, ): Result { - Timber.d("Sending video ${file.path.hash()}") + Timber.tag(loggerTag).d("Sending video ${file.path.hash()}") return sendAttachment(listOfNotNull(file, thumbnailFile)) { inner.sendVideo( params = UploadParameters( @@ -421,7 +425,7 @@ class RustTimeline( formattedCaption: String?, inReplyToEventId: EventId?, ): Result { - Timber.d("Sending audio ${file.path.hash()}") + Timber.tag(loggerTag).d("Sending audio ${file.path.hash()}") return sendAttachment(listOf(file)) { inner.sendAudio( params = UploadParameters( @@ -445,7 +449,7 @@ class RustTimeline( formattedCaption: String?, inReplyToEventId: EventId?, ): Result { - Timber.d("Sending file ${file.path.hash()}") + Timber.tag(loggerTag).d("Sending file ${file.path.hash()}") return sendAttachment(listOf(file)) { inner.sendFile( params = UploadParameters( @@ -475,7 +479,7 @@ class RustTimeline( runCatchingExceptions { roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds) }.onFailure { - Timber.e(it) + Timber.tag(loggerTag).e(it) } } From f389eae8684e334c7faddf86467245ca56398eea Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:46:18 +0300 Subject: [PATCH 34/53] Make media captions scrollable (#6498) --- .../impl/viewer/MediaViewerStateProvider.kt | 24 +++++++++++ .../impl/viewer/MediaViewerView.kt | 43 ++++++++++++++++--- ...ewer.impl.viewer_MediaViewerView_17_en.png | 3 ++ 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_17_en.png diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 85aecc41a8..b1a2bde350 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -29,6 +29,14 @@ import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirm import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState import kotlinx.collections.immutable.toImmutableList +private const val LONG_CAPTION = "This is a very long caption that should be scrollable in the media viewer. " + + "It contains multiple lines of text to demonstrate the scrolling behavior. " + + "Line 1: Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Line 2: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " + + "Line 3: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. " + + "Line 4: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum. " + + "Line 5: Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia." + open class MediaViewerStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( @@ -170,6 +178,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider ) ) ), + anImageMediaInfo( + senderName = "Alice", + dateSent = "21 NOV, 2024", + caption = LONG_CAPTION, + ).let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 7439909330..e5bed86eee 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -20,10 +20,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.TopAppBarDefaults @@ -39,6 +42,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged @@ -538,19 +542,44 @@ private fun MediaViewerBottomBar( if (showDivider) { HorizontalDivider() } - Text( + val scrollState = rememberScrollState() + val showBottomShadow = scrollState.value < scrollState.maxValue + Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - text = caption, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - style = ElementTheme.typography.fontBodyLgRegular, - ) + .heightIn(max = maxCaptionHeight), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(scrollState), + text = caption, + style = ElementTheme.typography.fontBodyLgRegular, + ) + if (showBottomShadow) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .align(Alignment.BottomCenter) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + bgCanvasWithTransparency, + ), + ), + ), + ) + } + } } } } +private val maxCaptionHeight = 200.dp + @Composable private fun ThumbnailView( thumbnailSource: MediaSource?, diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_17_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_17_en.png new file mode 100644 index 0000000000..dc06b5afc9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e353d24c2b9b49abaa11e064b1581215ad65154d0e67372fe24684ba2695a0e +size 442063 From 70e45d9bfec67e465a58806b53aac976f8f67937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 Apr 2026 09:34:29 +0200 Subject: [PATCH 35/53] Some tweaks for landscape and insets --- .../impl/viewer/MediaViewerView.kt | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index e5bed86eee..01f616aaca 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -17,10 +17,12 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -32,6 +34,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -46,7 +49,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -157,10 +162,11 @@ fun MediaViewerView( // So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose. page == pagerState.settledPage } + val navigationBarPadding = WindowInsets.navigationBars.getBottom(LocalDensity.current) MediaViewerPage( isDisplayed = isDisplayed, showOverlay = showOverlay, - bottomPaddingInPixels = bottomPaddingInPixels, + bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0), data = dataForPage, textFileViewer = textFileViewer, onDismiss = onBackClick, @@ -179,9 +185,7 @@ fun MediaViewerView( // Bottom bar AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + modifier = Modifier.fillMaxSize() ) { MediaViewerBottomBar( modifier = Modifier.align(Alignment.BottomCenter), @@ -543,17 +547,21 @@ private fun MediaViewerBottomBar( HorizontalDivider() } val scrollState = rememberScrollState() - val showBottomShadow = scrollState.value < scrollState.maxValue + val showBottomShadow by remember { derivedStateOf { scrollState.value < scrollState.maxValue } } + val isLandscape = with(LocalWindowInfo.current) { + containerDpSize.width > containerDpSize.height + } Box( modifier = Modifier .fillMaxWidth() - .heightIn(max = maxCaptionHeight), + .heightIn(max = if (isLandscape) maxCaptionHeightLandscape else maxCaptionHeightPortrait), ) { Text( modifier = Modifier .fillMaxWidth() .padding(16.dp) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .navigationBarsPadding(), text = caption, style = ElementTheme.typography.fontBodyLgRegular, ) @@ -578,7 +586,8 @@ private fun MediaViewerBottomBar( } } -private val maxCaptionHeight = 200.dp +private val maxCaptionHeightPortrait = 200.dp +private val maxCaptionHeightLandscape = 128.dp @Composable private fun ThumbnailView( From 42a572cce2c374c55ba4297d5a6fed556e4b266b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 Apr 2026 10:00:20 +0200 Subject: [PATCH 36/53] Add landscape previews --- .../mediaviewer/impl/viewer/MediaViewerView.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 01f616aaca..b27e8017d6 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -111,8 +111,12 @@ fun MediaViewerView( val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) var showOverlay by remember { mutableStateOf(true) } - val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 val currentData = state.listData.getOrNull(state.currentIndex) + val isLandscape = with(LocalWindowInfo.current) { + containerDpSize.width > containerDpSize.height + } + val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !isLandscape) 303 else 0 + BackHandler { onBackClick() } Scaffold( modifier, @@ -642,3 +646,14 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider:: onBackClick = {}, ) } + +@Preview(device = "spec:width=411dp,height=891dp, orientation=landscape") +@Composable +internal fun MediaViewerViewLandscapePreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { + MediaViewerView( + state = state, + audioFocus = null, + textFileViewer = { _, _ -> }, + onBackClick = {}, + ) +} From 9a5ed43f801c2267b41378226a34c3c81e7a3d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 Apr 2026 10:24:57 +0200 Subject: [PATCH 37/53] Try generating landscape screenshots again --- tests/uitests/src/test/kotlin/base/ScreenshotTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt index 0b088b5de0..a611fa83cf 100644 --- a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt +++ b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt @@ -23,6 +23,7 @@ import app.cash.paparazzi.Paparazzi import app.cash.paparazzi.RenderExtension import app.cash.paparazzi.TestName import com.android.resources.NightMode +import com.android.resources.ScreenOrientation import io.element.android.compound.theme.ElementTheme import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview @@ -122,6 +123,7 @@ object PaparazziPreviewRule { ): Paparazzi { val densityScale = deviceConfig.density.dpiValue / 160f val customScreenHeight = preview.previewInfo.heightDp.takeIf { it >= 0 }?.let { it * densityScale }?.toInt() + val isLandscape = preview.previewInfo.device.contains("landscape") return Paparazzi( deviceConfig = deviceConfig.copy( nightMode = when (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) { @@ -131,6 +133,7 @@ object PaparazziPreviewRule { locale = locale, softButtons = false, screenHeight = customScreenHeight ?: deviceConfig.screenHeight, + orientation = if (isLandscape) ScreenOrientation.LANDSCAPE else ScreenOrientation.PORTRAIT, ), maxPercentDifference = 0.01, renderExtensions = renderExtensions, From 15eca13ff8d0d5849c9439e035ebc483440a77da Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 15 Apr 2026 08:43:40 +0000 Subject: [PATCH 38/53] Update screenshots --- ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_0_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_10_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_11_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_12_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_13_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_14_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_15_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_16_en.png | 3 +++ ....mediaviewer.impl.viewer_MediaViewerViewLandscape_17_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_1_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_2_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_3_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_4_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_5_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_6_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_7_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_8_en.png | 3 +++ ...s.mediaviewer.impl.viewer_MediaViewerViewLandscape_9_en.png | 3 +++ 18 files changed, 54 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_11_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_12_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_13_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_14_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_15_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_16_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_17_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_0_en.png new file mode 100644 index 0000000000..60c5e812ba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e514d7f36fe25150d66c2d2092982a696196a5a0a2674eef97b7231c0c03bef +size 699410 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_10_en.png new file mode 100644 index 0000000000..5a96554c0a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:712af2723a0656ffe927f1cd488d3117f5b450fa405dad307b0c88a9f2483f9c +size 698704 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_11_en.png new file mode 100644 index 0000000000..e4424188e6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d6ab1331e65a4d02accb4d20c899a06c37a022d9f9080133799e30763969300 +size 26774 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_12_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_12_en.png new file mode 100644 index 0000000000..3dccb4c068 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:010120cbcbfed1e7bfa3fb4c88df6e1098d0382cb1834f1a8046e312434f201d +size 26549 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_13_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_13_en.png new file mode 100644 index 0000000000..d0139ec184 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_13_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52174b55b1737787260a454c59f243b0e3f6327ef5ec71464744def928d165d4 +size 206785 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_14_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_14_en.png new file mode 100644 index 0000000000..71ca7c633d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_14_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47c2e2194283803c55553c0220400ed8991539f25b772e0dbdde6d5defa3d3d4 +size 7343 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_15_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_15_en.png new file mode 100644 index 0000000000..4e8d03c6a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_15_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1e56f491ccc4c9ab82dde36a8fca588bdf037fb59b70ea47ffe13a56e22f801 +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_16_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_16_en.png new file mode 100644 index 0000000000..a32a45029c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_16_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:99478fe3d9d44e9cda39f33748c70017203a2cce5cb855605740a29360221bd5 +size 184910 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_17_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_17_en.png new file mode 100644 index 0000000000..a855818ac6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:062f8342a3a3715498ca34488c2d9ffe09c1e6dbbe04df09e88fd107a33b174a +size 653228 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_1_en.png new file mode 100644 index 0000000000..accbd26985 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3effa629295c12d2248924cf298599f3bd28975ecc76fac84f771c16266d738e +size 698895 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_2_en.png new file mode 100644 index 0000000000..b9dabc97ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fd3b7065860d1e51fe51a0a6e3f4a9e61a77ea91cefe85cc3aa3560c4cc6bb2 +size 252253 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_3_en.png new file mode 100644 index 0000000000..f11d4fc3fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f9ee78e2a034f34d77a438d305a5cc63ea583df80f83a14b1d81fcf74ca93f4 +size 665416 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_4_en.png new file mode 100644 index 0000000000..5e081d3c4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1e91c66363a5af74d4345f7837db178410c078e390b0cf9296d0ba4b3dfd7cb +size 207004 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_5_en.png new file mode 100644 index 0000000000..d61e0a50b9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9eb950f3c0d34a796ebd7635ff736023742d5e6a243912f3ad2234ecf08694b2 +size 183220 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_6_en.png new file mode 100644 index 0000000000..f2352c88f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f6c84fbd21949a3d9d355bfb627b8a70f65fc669cb6b6818a325fe4577351f9 +size 196092 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_7_en.png new file mode 100644 index 0000000000..bd37e3eb79 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26bd927acc578cb3585f6e101c066f0a6adb6a6e424dcd196388d5a838d8b22b +size 196396 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_8_en.png new file mode 100644 index 0000000000..69515d4326 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c4440f5ed01f2c1b405bb2d444c52455ea8ee1c094baf7d2f85bd9a9ff98b02 +size 210117 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_9_en.png new file mode 100644 index 0000000000..5d254988c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerViewLandscape_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11f140569061e21e6b546118b57340a0830779580498a31627acb2e6c7313471 +size 210490 From 69fb344517af8bfe512a0cb48e391b2288c5e340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 Apr 2026 13:16:37 +0200 Subject: [PATCH 39/53] Use `WindowSizeClass` instead of just checking portrait/landscape orientation --- .../utils/WindowSizeClassUtils.kt | 19 +++++++++++++++++++ .../impl/viewer/MediaViewerView.kt | 12 +++--------- 2 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowSizeClassUtils.kt diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowSizeClassUtils.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowSizeClassUtils.kt new file mode 100644 index 0000000000..ba0752e8a2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowSizeClassUtils.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.libraries.designsystem.utils + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun hasCompactHeightWindowSize(): Boolean { + return currentWindowAdaptiveInfo().windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index b27e8017d6..a3cc0e2b9f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -78,6 +77,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Scaffold 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.hasCompactHeightWindowSize 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.media.MediaSource @@ -112,10 +112,7 @@ fun MediaViewerView( var showOverlay by remember { mutableStateOf(true) } val currentData = state.listData.getOrNull(state.currentIndex) - val isLandscape = with(LocalWindowInfo.current) { - containerDpSize.width > containerDpSize.height - } - val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !isLandscape) 303 else 0 + val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !hasCompactHeightWindowSize()) 303 else 0 BackHandler { onBackClick() } Scaffold( @@ -552,13 +549,10 @@ private fun MediaViewerBottomBar( } val scrollState = rememberScrollState() val showBottomShadow by remember { derivedStateOf { scrollState.value < scrollState.maxValue } } - val isLandscape = with(LocalWindowInfo.current) { - containerDpSize.width > containerDpSize.height - } Box( modifier = Modifier .fillMaxWidth() - .heightIn(max = if (isLandscape) maxCaptionHeightLandscape else maxCaptionHeightPortrait), + .heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait), ) { Text( modifier = Modifier From 7cdad11753007cd7fc2d32e9a335155b0b189e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 Apr 2026 15:14:09 +0200 Subject: [PATCH 40/53] Use `Devices.PHONE` directly in the landscape preview --- .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index a3cc0e2b9f..95ce7c631f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -641,7 +642,7 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider:: ) } -@Preview(device = "spec:width=411dp,height=891dp, orientation=landscape") +@Preview(device = "${Devices.PHONE}, orientation=landscape") @Composable internal fun MediaViewerViewLandscapePreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { MediaViewerView( From b21f7cd18ffc85a231564abe3cd1989ec36e4891 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:32:12 +0000 Subject: [PATCH 41/53] Update dependency io.element.android:element-call-embedded to v0.19.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b867bef5f1..71e31f6cf6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -233,7 +233,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.19.0" +element_call_embedded = "io.element.android:element-call-embedded:0.19.1" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } From 42bd404a20bc7bbad68c78782e50e38b51ead4c0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:39:35 +0000 Subject: [PATCH 42/53] Update dependency androidx.annotation:annotation-jvm to v1.10.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 b867bef5f1..4f4d9a82be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,7 +89,7 @@ google_tink = "com.google.crypto.tink:tink-android:1.21.0" # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } -androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.1" +androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.10.0" androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.4.2" From d8c540956a563e6eb40e4bc694bb8a78b860ae21 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:56:48 +0200 Subject: [PATCH 43/53] Update dependency org.matrix.rustcomponents:sdk-android to v26.04.15 (#6595) 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 b867bef5f1..068516bcd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -178,7 +178,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.04.13" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.04.15" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } From 67c0e4c1406f9d147930310c4e5c9e386760449a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:26:20 +0200 Subject: [PATCH 44/53] Update nschloe/action-cached-lfs-checkout action to v1.2.5 (#6600) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/generate_github_pages.yml | 2 +- .github/workflows/nightlyReports.yml | 2 +- .github/workflows/recordScreenshots.yml | 4 ++-- .github/workflows/tests.yml | 2 +- .github/workflows/validate-lfs.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index ae87dd6d50..55a300dd88 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - name: ⏬ Checkout with LFS - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 + uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 33660b1184..314929d801 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -34,7 +34,7 @@ jobs: swap-storage: false - name: ⏬ Checkout with LFS - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 + uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index 70bbbe5d0a..0f4c8ee581 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -43,13 +43,13 @@ jobs: labels: Record-Screenshots - name: ⏬ Checkout with LFS (PR) if: github.event.label.name == 'Record-Screenshots' - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 + uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 with: persist-credentials: false ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }} - name: ⏬ Checkout with LFS (Branch) if: github.event_name == 'workflow_dispatch' - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 + uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 with: persist-credentials: false - name: ☕️ Use JDK 21 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 03882c7d44..0ce66df478 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,7 +49,7 @@ jobs: sudo swapon /mnt/swapfile sudo swapon --show - name: ⏬ Checkout with LFS - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 + uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 diff --git a/.github/workflows/validate-lfs.yml b/.github/workflows/validate-lfs.yml index c3158291c3..027c7d68e9 100644 --- a/.github/workflows/validate-lfs.yml +++ b/.github/workflows/validate-lfs.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest name: Validate steps: - - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 + - uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - run: | ./tools/git/validate_lfs.sh From c349f74ce80860256bcb2eb40cfcef469f81583c Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 16 Apr 2026 16:19:29 +0200 Subject: [PATCH 45/53] Fix loading initial items of non-live timelines (#6598) This was done automatically by the SDK in the past by returning a `Reset` timeline update, but this behaviour changed and now we have to do it. --- .../messages/impl/timeline/TimelineView.kt | 10 ++++-- .../messages/impl/MessagesViewTest.kt | 9 +++++ .../impl/timeline/TimelineViewTest.kt | 33 +++++++++++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0137b1736d..0c5bb28890 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch import timber.log.Timber @@ -262,11 +263,16 @@ private fun TimelinePrefetchingHelper( firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 } + // If we have no timeline items, we need to back paginate to load some messages. This usually happens on all timelines except for live ones. + // This automatic pagination was previously done by the SDK, and we received a `Reset` update, but now we need to do it ourselves. + val isEmptyTimelineFlow = layoutInfoFlow.map { it.totalItemsCount == 0 } + combine( isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(), isScrollingFlow.distinctUntilChanged(), - ) { needsPrefetch, isScrolling -> - needsPrefetch && isScrolling + isEmptyTimelineFlow, + ) { needsPrefetch, isScrolling, isEmptyAndNeedsBackPagination -> + isEmptyAndNeedsBackPagination || needsPrefetch && isScrolling } .distinctUntilChanged() .collectLatest { needsPrefetch -> 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 ff4bc37fa3..62b9eac68d 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 @@ -522,6 +522,9 @@ class MessagesViewTest { rule.setMessagesView( state = stateWithActionListState, ) + // Clear initial 'LoadMore' event emitted when setting the state + eventsRecorder.clear() + val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice") rule.onNodeWithText(verifiedUserSendFailure).performClick() // Give time for the close animation to complete @@ -585,6 +588,9 @@ class MessagesViewTest { ), ) rule.setMessagesView(state = state) + // Clear initial 'LoadMore' event emitted when setting the state + eventsRecorder.clear() + rule.onNodeWithText("This is a pinned message").performClick() eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)) } @@ -601,6 +607,9 @@ class MessagesViewTest { timelineState = aTimelineState(eventSink = eventsRecorder) ) rule.setMessagesView(state = state) + // Clear initial 'LoadMore' event emitted when setting the state + eventsRecorder.clear() + val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action) // The bottomsheet subcompose seems to make the node to appear twice rule.onAllNodesWithText(text).onFirst().performClick() 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 3a97fbd9dc..c05625e2d3 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 @@ -67,24 +67,31 @@ class TimelineViewTest { @Test fun `reaching the end of the timeline does not send a LoadMore event`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), eventSink = eventsRecorder, ), ) + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) } @Test fun `scroll to bottom on live timeline does not emit the Event`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), isLive = true, eventSink = eventsRecorder, ), forceJumpToBottomVisibility = true, ) + + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) rule.onNodeWithContentDescription(contentDescription).performClick() } @@ -94,15 +101,33 @@ class TimelineViewTest { val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), isLive = false, eventSink = eventsRecorder, ), ) + + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) rule.onNodeWithContentDescription(contentDescription).performClick() eventsRecorder.assertSingle(TimelineEvent.JumpToLive) } + @Test + fun `an empty timeline triggers a prefetch`() { + val eventsRecorder = EventsRecorder() + rule.setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(), + eventSink = eventsRecorder, + ), + ) + + eventsRecorder.assertSingle(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + } + @Test fun `show shield dialog`() { val eventsRecorder = EventsRecorder() @@ -133,11 +158,15 @@ class TimelineViewTest { val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), isLive = false, eventSink = eventsRecorder, messageShield = aCriticalShield(), ), ) + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + rule.clickOn(CommonStrings.action_ok) eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) } From 228f188b5ab36c5e60d60a6b8a30ef4ff0ec02ed Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 16 Apr 2026 16:39:44 +0200 Subject: [PATCH 46/53] Setting version for the release 26.04.3 --- plugins/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index f46abc71bf..93e92b81ce 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 4 * 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 = 2 +private const val versionReleaseNumber = 3 object Versions { /** From d7118bfad8bb846bae67b904da28c85efa346cf6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 16 Apr 2026 16:40:04 +0200 Subject: [PATCH 47/53] Adding fastlane file for version 26.04.3 --- fastlane/metadata/android/en-US/changelogs/202604030.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202604030.txt diff --git a/fastlane/metadata/android/en-US/changelogs/202604030.txt b/fastlane/metadata/android/en-US/changelogs/202604030.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202604030.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 2e4c9954b77ffd4be2a212626afe407c9066bd41 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 16 Apr 2026 17:34:48 +0200 Subject: [PATCH 48/53] Changelog for version 26.04.3 --- CHANGES.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a3d7870288..68848d491f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,62 @@ +Changes in Element X v26.04.3 +============================= + + + +## What's Changed +### ✨ Features +* Sign in with element classic final by @bmarty in https://github.com/element-hq/element-x-android/pull/6296 +* Take into account homeserver capabilities by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6507 +### 🙌 Improvements +* feat: Default to camera muted when joining ongoing voice call by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6574 +### 🐛 Bugfixes +* Fix crash in FetchPushForegroundService: No super method onTimeout by @bmarty in https://github.com/element-hq/element-x-android/pull/6547 +* Ensure mark as fully read is called only once when leaving the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/6550 +* Fix `isInAirGappedEnvironment` check for older APIs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6573 +* Fix loading initial items of non-live timelines by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6598 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6537 +* Sync Strings - new translations in Japanese and Vietnamese by @ElementBot in https://github.com/element-hq/element-x-android/pull/6568 +### 🧱 Build +* Fix module dependencies by @bmarty in https://github.com/element-hq/element-x-android/pull/6559 +### 🚧 In development 🚧 +* Add confirmation dialog when inviting users with unknown identities by @kaylendog in https://github.com/element-hq/element-x-android/pull/6523 +* Feature: add room threads list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6575 +### Dependency upgrades +* fix(deps): update media3 to v1.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6529 +* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.8.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6525 +* fix(deps): update metro to v0.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6543 +* fix(deps): update kotlinpoet to v2.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6528 +* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v10.0.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/6517 +* fix(deps): update dependency io.sentry:sentry-android to v8.37.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6508 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v13.0.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6546 +* Update codecov/codecov-action action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6521 +* Update telephoto to v0.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6558 +* Update dependency net.zetetic:sqlcipher-android to v4.14.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6552 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6553 +* Update gradle/actions action to v6.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6562 +* Update metro to v0.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6565 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6570 +* Update wysiwyg to v2.41.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6572 +* Update dependency com.google.testparameterinjector:test-parameter-injector to v1.22 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6576 +* Use `Coil3` for `ZoomableAsyncImage` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6582 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.15 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6595 +* Update nschloe/action-cached-lfs-checkout action to v1.2.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6600 +### Others +* Fix portrait image metadata when uploading without media optimization by @kalix127 in https://github.com/element-hq/element-x-android/pull/6362 +* Fix Threads not tappable in pinned messages list by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6535 +* Reduce log level of activity lifecycle from warning to debug. by @bmarty in https://github.com/element-hq/element-x-android/pull/6548 +* Remove spaces features flags by @bmarty in https://github.com/element-hq/element-x-android/pull/6560 +* Remove space announcement by @bmarty in https://github.com/element-hq/element-x-android/pull/6561 +* Update metro to v0.13.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6571 +* Take into account the value of FeatureFlags.SignInWithClassic by @bmarty in https://github.com/element-hq/element-x-android/pull/6586 +* Add extra logs for timeline pagination by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6589 +* Scrollable media caption - tweaks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6583 +* Split developer settings into 2 screens to be able to access global settings when no logged in. by @bmarty in https://github.com/element-hq/element-x-android/pull/6587 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.04.2...v26.04.3 + Changes in Element X v26.04.2 ============================= From 5b3f91ff7877ce46a275c2963296a0a7d04dc4be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:01:20 +0200 Subject: [PATCH 49/53] Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.11.0 (#6605) 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 63428fd6f4..427bbd1042 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ roborazzi = "1.59.0" # Jetbrain datetime = "0.7.1" -serialization_json = "1.10.0" +serialization_json = "1.11.0" #other coil = "3.4.0" From f661ccf25ae6667fa0cec56d5bff1a57613e6293 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:43:32 +0200 Subject: [PATCH 50/53] Update dependency com.google.firebase:firebase-bom to v34.12.0 (#6604) 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 427bbd1042..07442e3c62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,7 +80,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:34.11.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.12.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } From a1c9994385797a71c0007e33b6adc6ac92ac91a4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 16 Apr 2026 12:39:08 +0200 Subject: [PATCH 51/53] Settings UI update. - Reorder items - Minor UI update - Improve the previews of the Composable - Merge manage account and manage devices - Add missing tests --- .../appnav/loggedin/LoggedInPresenter.kt | 4 +- .../appnav/loggedin/LoggedInPresenterTest.kt | 9 +- ...sRootEvents.kt => PreferencesRootEvent.kt} | 6 +- .../impl/root/PreferencesRootPresenter.kt | 23 +- .../impl/root/PreferencesRootState.kt | 9 +- .../impl/root/PreferencesRootStateProvider.kt | 101 +++- .../impl/root/PreferencesRootView.kt | 133 ++--- .../preferences/impl/user/UserPreferences.kt | 8 +- .../impl/root/PreferencesRootPresenterTest.kt | 61 ++- .../impl/root/PreferencesRootViewTest.kt | 483 ++++++++++++++++++ .../components/avatar/AvatarSize.kt | 2 +- .../preview/ElementPreviewDark.kt | 9 +- .../preview/ElementPreviewLight.kt | 9 +- .../matrix/ui/components/MatrixUserHeader.kt | 36 +- .../components/MatrixUserHeaderPlaceholder.kt | 64 --- .../ui/components/MatrixUserProvider.kt | 9 - .../matrix/ui/components/MatrixUserRow.kt | 4 + .../libraries/matrix/ui/components/UserRow.kt | 11 +- 18 files changed, 733 insertions(+), 248 deletions(-) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/{PreferencesRootEvents.kt => PreferencesRootEvent.kt} (78%) create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt delete mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 8dc2de5e4e..752d10e7a9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -31,7 +31,6 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState -import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -177,7 +176,6 @@ class LoggedInPresenter( } private fun CoroutineScope.preloadAccountManagementUrl() = launch { - matrixClient.getAccountManagementUrl(AccountManagementAction.Profile) - matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList) + matrixClient.getAccountManagementUrl(null) } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index d147a4ed68..902f446a6f 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -71,7 +71,7 @@ class LoggedInPresenterTest { } @Test - fun `present - ensure that account urls are preloaded`() = runTest { + fun `present - ensure that account url is preloaded`() = runTest { val accountManagementUrlResult = lambdaRecorder> { Result.success("aUrl") } val matrixClient = FakeMatrixClient( accountManagementUrlResult = accountManagementUrlResult, @@ -81,11 +81,8 @@ class LoggedInPresenterTest { ).test { awaitItem() advanceUntilIdle() - accountManagementUrlResult.assertions().isCalledExactly(2) - .withSequence( - listOf(value(AccountManagementAction.Profile)), - listOf(value(AccountManagementAction.DevicesList)), - ) + accountManagementUrlResult.assertions().isCalledOnce() + .with(value(null)) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt similarity index 78% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index be266869be..5a10a50ba6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -10,7 +10,7 @@ package io.element.android.features.preferences.impl.root import io.element.android.libraries.matrix.api.core.SessionId -sealed interface PreferencesRootEvents { - data object OnVersionInfoClick : PreferencesRootEvents - data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents +sealed interface PreferencesRootEvent { + data object OnVersionInfoClick : PreferencesRootEvent + data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 3d6a829167..43da681a37 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -30,7 +30,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.sessionstorage.api.SessionStore @@ -99,9 +98,6 @@ class PreferencesRootPresenter( val accountManagementUrl: MutableState = remember { mutableStateOf(null) } - val devicesManagementUrl: MutableState = remember { - mutableStateOf(null) - } var canDeactivateAccount by remember { mutableStateOf(false) } @@ -110,9 +106,9 @@ class PreferencesRootPresenter( canDeactivateAccount = matrixClient.canDeactivateAccount() } - val showBlockedUsersItem by produceState(initialValue = false) { + val nbOfBlockedUsers by produceState(initialValue = 0) { matrixClient.ignoredUsersFlow - .onEach { value = it.isNotEmpty() } + .onEach { value = it.size } .launchIn(this) } @@ -121,17 +117,17 @@ class PreferencesRootPresenter( val directLogoutState = directLogoutPresenter.present() LaunchedEffect(Unit) { - initAccountManagementUrl(accountManagementUrl, devicesManagementUrl) + initAccountManagementUrl(accountManagementUrl) } val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() - fun handleEvent(event: PreferencesRootEvents) { + fun handleEvent(event: PreferencesRootEvent) { when (event) { - is PreferencesRootEvents.OnVersionInfoClick -> { + is PreferencesRootEvent.OnVersionInfoClick -> { showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } - is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch { + is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { sessionStore.setLatestSession(event.sessionId.value) } } @@ -146,13 +142,12 @@ class PreferencesRootPresenter( showSecureBackup = !canVerifyUserSession, showSecureBackupBadge = showSecureBackupIndicator, accountManagementUrl = accountManagementUrl.value, - devicesManagementUrl = devicesManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, canReportBug = canReportBug, showLinkNewDevice = showLinkNewDevice, showDeveloperSettings = showDeveloperSettings, canDeactivateAccount = canDeactivateAccount, - showBlockedUsersItem = showBlockedUsersItem, + nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, @@ -162,9 +157,7 @@ class PreferencesRootPresenter( private fun CoroutineScope.initAccountManagementUrl( accountManagementUrl: MutableState, - devicesManagementUrl: MutableState, ) = launch { - accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull() - devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList).getOrNull() + accountManagementUrl.value = matrixClient.getAccountManagementUrl(null).getOrNull() } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index d637ae6c87..6474f07a69 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -23,15 +23,16 @@ data class PreferencesRootState( val showSecureBackup: Boolean, val showSecureBackupBadge: Boolean, val accountManagementUrl: String?, - val devicesManagementUrl: String?, val canReportBug: Boolean, val showLinkNewDevice: Boolean, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val canDeactivateAccount: Boolean, - val showBlockedUsersItem: Boolean, + val nbOfBlockedUsers: Int, val showLabsItem: Boolean, val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, - val eventSink: (PreferencesRootEvents) -> Unit, -) + val eventSink: (PreferencesRootEvent) -> Unit, +) { + val showBlockedUsersItem = nbOfBlockedUsers > 0 +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index b8d1f1c2b6..d53cd008e4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -8,36 +8,103 @@ package io.element.android.features.preferences.impl.root +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList +open class PreferencesRootStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Nominal state, that a regular user will see if multi account is enabled + aPreferencesRootState( + myUser = aMatrixUser(avatarUrl = "anAvatarUrl"), + version = "Version 1.1 (1)", + deviceId = DeviceId("ILAKNDNASDLK"), + isMultiAccountEnabled = true, + otherSessions = aMatrixUserList().drop(1).take(1), + showSecureBackup = true, + accountManagementUrl = "aUrl", + canReportBug = true, + showLinkNewDevice = true, + showAnalyticsSettings = true, + canDeactivateAccount = false, + nbOfBlockedUsers = 3, + showLabsItem = true, + ), + aPreferencesRootState( + myUser = aMatrixUser(displayName = null), + isMultiAccountEnabled = true, + showSecureBackup = true, + canDeactivateAccount = true, + ), + aPreferencesRootState( + isMultiAccountEnabled = true, + otherSessions = aMatrixUserList().drop(1).take(3), + accountManagementUrl = "aUrl", + showSecureBackup = true, + showSecureBackupBadge = true, + ), + aPreferencesRootState( + deviceId = DeviceId("ILAKNDNASDLK"), + showLabsItem = true, + canReportBug = true, + nbOfBlockedUsers = 3, + snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), + ), + aPreferencesRootState( + showLinkNewDevice = true, + showAnalyticsSettings = true, + showDeveloperSettings = true, + canDeactivateAccount = true, + ), + // Minimal state + aPreferencesRootState(), + ) +} + fun aPreferencesRootState( myUser: MatrixUser = aMatrixUser(), + version: String = "Version 1.1 (1)", + deviceId: DeviceId? = null, + isMultiAccountEnabled: Boolean = false, otherSessions: List = emptyList(), - eventSink: (PreferencesRootEvents) -> Unit = { _ -> }, + showSecureBackup: Boolean = false, + showSecureBackupBadge: Boolean = false, + accountManagementUrl: String? = null, + canReportBug: Boolean = false, + showLinkNewDevice: Boolean = false, + showAnalyticsSettings: Boolean = false, + showDeveloperSettings: Boolean = false, + canDeactivateAccount: Boolean = false, + nbOfBlockedUsers: Int = 0, + showLabsItem: Boolean = false, + directLogoutState: DirectLogoutState = aDirectLogoutState(), + snackbarMessage: SnackbarMessage? = null, + eventSink: (PreferencesRootEvent) -> Unit = {}, ) = PreferencesRootState( myUser = myUser, - version = "Version 1.1 (1)", - deviceId = DeviceId("ILAKNDNASDLK"), - isMultiAccountEnabled = true, + version = version, + deviceId = deviceId, + isMultiAccountEnabled = isMultiAccountEnabled, otherSessions = otherSessions.toImmutableList(), - showSecureBackup = true, - showSecureBackupBadge = true, - accountManagementUrl = "aUrl", - devicesManagementUrl = "anOtherUrl", - showAnalyticsSettings = true, - showLinkNewDevice = true, - canReportBug = true, - showDeveloperSettings = true, - showBlockedUsersItem = true, - showLabsItem = true, - canDeactivateAccount = true, - snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), - directLogoutState = aDirectLogoutState(), + showSecureBackup = showSecureBackup, + showSecureBackupBadge = showSecureBackupBadge, + accountManagementUrl = accountManagementUrl, + canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, + showAnalyticsSettings = showAnalyticsSettings, + showDeveloperSettings = showDeveloperSettings, + canDeactivateAccount = canDeactivateAccount, + nbOfBlockedUsers = nbOfBlockedUsers, + showLabsItem = showLabsItem, + directLogoutState = directLogoutState, + snackbarMessage = snackbarMessage, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 5e3c9d6759..32ecfc2b13 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -9,7 +9,6 @@ package io.element.android.features.preferences.impl.root import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -28,23 +27,20 @@ import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage -import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight -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.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.CommonDrawables 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.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.components.MatrixUserProvider import io.element.android.libraries.matrix.ui.components.MatrixUserRow -import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -82,22 +78,17 @@ fun PreferencesRootView( modifier = Modifier.clickable { onOpenUserProfile(state.myUser) }, - user = state.myUser, + matrixUser = state.myUser, ) if (state.isMultiAccountEnabled) { MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, ) + } else { + HorizontalDivider() } - // 'Manage my app' section - ManageAppSection( - state = state, - onOpenNotificationSettings = onOpenNotificationSettings, - onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClick = onSecureBackupClick, - ) - + // User status will be added here // 'Account' section ManageAccountSection( state = state, @@ -105,6 +96,13 @@ fun PreferencesRootView( onLinkNewDeviceClick = onLinkNewDeviceClick, onOpenBlockedUsers = onOpenBlockedUsers ) + // 'Manage my app' section + ManageAppSection( + state = state, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenLockScreenSettings = onOpenLockScreenSettings, + onSecureBackupClick = onSecureBackupClick, + ) // General section GeneralSection( @@ -118,12 +116,12 @@ fun PreferencesRootView( onSignOutClick = onSignOutClick, onDeactivateClick = onDeactivateClick, ) - + // Version Footer( version = state.version, deviceId = state.deviceId, onClick = if (!state.showDeveloperSettings) { - { state.eventSink(PreferencesRootEvents.OnVersionInfoClick) } + { state.eventSink(PreferencesRootEvent.OnVersionInfoClick) } } else { null } @@ -142,13 +140,15 @@ private fun ColumnScope.MultiAccountSection( ) state.otherSessions.forEach { matrixUser -> MatrixUserRow( - modifier = Modifier.clickable { - state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId)) - }, + modifier = Modifier + .clickable { + state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + } + .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), matrixUser = matrixUser, avatarSize = AvatarSize.AccountItem, + verticalSpaceWidth = 16.dp, ) - HorizontalDivider() } ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), @@ -198,6 +198,14 @@ private fun ColumnScope.ManageAccountSection( onLinkNewDeviceClick: () -> Unit, onOpenBlockedUsers: () -> Unit, ) { + state.accountManagementUrl?.let { url -> + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), + onClick = { onManageAccountClick(url) }, + ) + } if (state.showLinkNewDevice) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, @@ -205,33 +213,15 @@ private fun ColumnScope.ManageAccountSection( onClick = onLinkNewDeviceClick, ) } - state.accountManagementUrl?.let { url -> - ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), - trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClick(url) }, - ) - } - - state.devicesManagementUrl?.let { url -> - ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), - trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClick(url) }, - ) - } - if (state.showBlockedUsersItem) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), onClick = onOpenBlockedUsers, + trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()), ) } - - if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) { + if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) { HorizontalDivider() } } @@ -248,6 +238,18 @@ private fun ColumnScope.GeneralSection( onSignOutClick: () -> Unit, onDeactivateClick: () -> Unit, ) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), + onClick = onOpenAdvancedSettings, + ) + if (state.showLabsItem) { + ListItem( + headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())), + onClick = onOpenLabs, + ) + } ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), @@ -267,30 +269,17 @@ private fun ColumnScope.GeneralSection( onClick = onOpenAnalytics, ) } - ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), - onClick = onOpenAdvancedSettings, - ) - - if (state.showLabsItem) { - ListItem( - headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())), - onClick = onOpenLabs, - ) - } - + HorizontalDivider() ListItem( headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), style = ListItemStyle.Destructive, onClick = onSignOutClick, ) if (state.canDeactivateAccount) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Warning())), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), style = ListItemStyle.Destructive, onClick = onDeactivateClick, ) @@ -319,9 +308,8 @@ private fun ColumnScope.Footer( Text( modifier = Modifier .align(Alignment.CenterHorizontally) - .padding(top = 16.dp) .clickable(enabled = onClick != null, onClick = onClick ?: {}) - .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 24.dp), + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp), textAlign = TextAlign.Center, text = text, style = ElementTheme.typography.fontBodySmRegular, @@ -340,19 +328,23 @@ private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) { @PreviewWithLargeHeight @Composable -internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = - ElementPreviewLight { ContentToPreview(matrixUser) } +internal fun PreferencesRootViewLightPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) = + ElementPreviewLight( + drawableFallbackForImages = CommonDrawables.sample_avatar, + ) { ContentToPreview(state) } @PreviewWithLargeHeight @Composable -internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = - ElementPreviewDark { ContentToPreview(matrixUser) } +internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) = + ElementPreviewDark( + drawableFallbackForImages = CommonDrawables.sample_avatar, + ) { ContentToPreview(state) } @ExcludeFromCoverage @Composable -private fun ContentToPreview(matrixUser: MatrixUser) { +private fun ContentToPreview(state: PreferencesRootState) { PreferencesRootView( - state = aPreferencesRootState(myUser = matrixUser), + state = state, onBackClick = {}, onAddAccountClick = {}, onOpenAnalytics = {}, @@ -372,16 +364,3 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onDeactivateClick = {}, ) } - -@PreviewsDayNight -@Composable -internal fun MultiAccountSectionPreview() = ElementPreview { - Column { - MultiAccountSection( - state = aPreferencesRootState( - otherSessions = aMatrixUserList(), - ), - onAddAccountClick = {}, - ) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt index a9066dcd73..43c7e8dacf 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt @@ -15,21 +15,21 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserHeader -import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider +import io.element.android.libraries.matrix.ui.components.MatrixUserProvider @Composable fun UserPreferences( - user: MatrixUser?, + matrixUser: MatrixUser, modifier: Modifier = Modifier, ) { MatrixUserHeader( modifier = modifier, - matrixUser = user + matrixUser = matrixUser, ) } @PreviewsDayNight @Composable -internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = ElementPreview { +internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { UserPreferences(matrixUser) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index f0dc58ef22..e5d48ec175 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -28,6 +28,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta @@ -40,7 +42,9 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -73,6 +77,7 @@ class PreferencesRootPresenterTest { assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.isMultiAccountEnabled).isFalse() assertThat(initialState.otherSessions).isEmpty() + assertThat(initialState.version).isEqualTo("A Version") val loadedState = awaitItem() assertThat(loadedState.myUser).isEqualTo( MatrixUser( @@ -81,27 +86,21 @@ class PreferencesRootPresenterTest { avatarUrl = AN_AVATAR_URL ) ) - assertThat(initialState.version).isEqualTo("A Version") assertThat(loadedState.showSecureBackup).isFalse() assertThat(loadedState.showSecureBackupBadge).isFalse() assertThat(loadedState.accountManagementUrl).isNull() - assertThat(loadedState.devicesManagementUrl).isNull() assertThat(loadedState.showAnalyticsSettings).isFalse() assertThat(loadedState.showLinkNewDevice).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.canDeactivateAccount).isTrue() assertThat(loadedState.canReportBug).isTrue() + assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0) assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState()) assertThat(loadedState.snackbarMessage).isNull() - skipItems(1) val finalState = awaitItem() - accountManagementUrlResult.assertions().isCalledExactly(2) - .withSequence( - listOf(value(AccountManagementAction.Profile)), - listOf(value(AccountManagementAction.DevicesList)), - ) - assertThat(finalState.accountManagementUrl).isEqualTo("Profile url") - assertThat(finalState.devicesManagementUrl).isEqualTo("DevicesList url") + accountManagementUrlResult.assertions().isCalledOnce() + .with(value(null)) + assertThat(finalState.accountManagementUrl).isEqualTo("null url") } } @@ -121,6 +120,22 @@ class PreferencesRootPresenterTest { } } + @Test + fun `present - number of blocked users`() = runTest { + val matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)), + ) + createPresenter( + matrixClient = matrixClient, + ).test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.nbOfBlockedUsers).isEqualTo(2) + } + } + @Test fun `present - secure backup badge`() = runTest { val matrixClient = FakeMatrixClient( @@ -181,12 +196,36 @@ class PreferencesRootPresenterTest { val loadedState = awaitFirstItem() repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) { assertThat(loadedState.showDeveloperSettings).isFalse() - loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick) + loadedState.eventSink(PreferencesRootEvent.OnVersionInfoClick) } assertThat(awaitItem().showDeveloperSettings).isTrue() } } + @Test + fun `present - switch session invoke method on the session store`() = runTest { + val setLatestSessionResult = lambdaRecorder { } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ), + setLatestSessionResult = setLatestSessionResult, + ) + createPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + sessionStore = sessionStore, + ).test { + val loadedState = awaitFirstItem() + loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) + setLatestSessionResult.assertions().isCalledOnce() + .with(value(A_SESSION_ID_2.value)) + } + } + @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { createPresenter( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt new file mode 100644 index 0000000000..e3c0d6e44d --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -0,0 +1,483 @@ +/* + * 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.impl.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.ui.components.aMatrixUser +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.EventsRecorder +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.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreferencesRootViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `click on User profile invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val user = aMatrixUser() + ensureCalledOnceWithParam(user) { callback -> + rule.setView( + aPreferencesRootState( + myUser = user, + eventSink = eventsRecorder, + ), + onOpenUserProfile = callback, + ) + rule.onNodeWithText("Alice").performClick() + } + } + + @Test + fun `clicking on other session sends a SwitchToSession`() { + val eventsRecorder = EventsRecorder() + rule.setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText("Bob").performClick() + eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) + } + + @Test + fun `click on Add account invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + eventSink = eventsRecorder, + ), + onAddAccountClick = callback, + ) + rule.clickOn(CommonStrings.common_add_another_account) + } + } + + @Test + fun `when multi account is not enabled, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + isMultiAccountEnabled = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_add_another_account)).assertDoesNotExist() + } + + @Test + fun `click on Encryption invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + showSecureBackup = true, + eventSink = eventsRecorder, + ), + onSecureBackupClick = callback, + ) + rule.clickOn(CommonStrings.common_encryption) + } + } + + @Test + fun `when showSecureBackup is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + showSecureBackup = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_encryption)).assertDoesNotExist() + } + + @Test + fun `click on Manage account invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam("aUrl") { callback -> + rule.setView( + aPreferencesRootState( + accountManagementUrl = "aUrl", + eventSink = eventsRecorder, + ), + onManageAccountClick = callback, + ) + rule.clickOn(CommonStrings.action_manage_account_and_devices) + } + } + + @Test + fun `when accountManagementUrl is null, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + accountManagementUrl = null, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist() + } + + @Test + fun `click on Link new devices invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + showLinkNewDevice = true, + eventSink = eventsRecorder, + ), + onLinkNewDeviceClick = callback, + ) + rule.clickOn(CommonStrings.common_link_new_device) + } + } + + @Test + fun `when showLinkNewDevice is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + showLinkNewDevice = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() + } + + @Test + fun `click on Analytics invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + showAnalyticsSettings = true, + eventSink = eventsRecorder, + ), + onOpenAnalytics = callback, + ) + rule.clickOn(CommonStrings.common_analytics) + } + } + + @Test + fun `when showAnalyticsSettings is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + showAnalyticsSettings = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_analytics)).assertDoesNotExist() + } + + @Test + fun `click on Report a problem invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + canReportBug = true, + eventSink = eventsRecorder, + ), + onOpenRageShake = callback, + ) + rule.clickOn(CommonStrings.common_report_a_problem) + } + } + + @Test + fun `when canReportBug is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + canReportBug = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist() + } + + @Test + fun `click on Screen lock invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onOpenLockScreenSettings = callback, + ) + rule.clickOn(CommonStrings.common_screen_lock) + } + } + + @Test + fun `click on About invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onOpenAbout = callback, + ) + rule.clickOn(CommonStrings.common_about) + } + } + + @Test + fun `click on Developer settings invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + showDeveloperSettings = true, + eventSink = eventsRecorder, + ), + onOpenDeveloperSettings = callback, + ) + rule.clickOn(CommonStrings.common_developer_options) + } + } + + @Test + fun `when showDeveloperSettings is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + showDeveloperSettings = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_developer_options)).assertDoesNotExist() + } + + @Test + fun `click on Advanced settings invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onOpenAdvancedSettings = callback, + ) + rule.clickOn(CommonStrings.common_advanced_settings) + } + } + + @Test + fun `click on Labs invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + showLabsItem = true, + eventSink = eventsRecorder, + ), + onOpenLabs = callback, + ) + rule.clickOn(R.string.screen_labs_title) + } + } + + @Test + fun `when showLabsItem is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + showLabsItem = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(R.string.screen_labs_title)).assertDoesNotExist() + } + + @Test + fun `click on Notification invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onOpenNotificationSettings = callback, + ) + rule.clickOn(R.string.screen_notification_settings_title) + } + } + + @Test + fun `click on Blocked users invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + nbOfBlockedUsers = 1, + eventSink = eventsRecorder, + ), + onOpenBlockedUsers = callback, + ) + rule.clickOn(CommonStrings.common_blocked_users) + } + } + + @Test + fun `when nbOfBlockedUsers is 0, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + nbOfBlockedUsers = 0, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.common_blocked_users)).assertDoesNotExist() + } + + @Test + fun `click on Remove this device invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onSignOutClick = callback, + ) + rule.clickOn(CommonStrings.action_signout) + } + } + + @Test + fun `click on Deactivate invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + aPreferencesRootState( + canDeactivateAccount = true, + eventSink = eventsRecorder, + ), + onDeactivateClick = callback, + ) + rule.clickOn(CommonStrings.action_deactivate_account) + } + } + + @Test + fun `when canDeactivateAccount is false, item is not shown`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setView( + aPreferencesRootState( + canDeactivateAccount = false, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_deactivate_account)).assertDoesNotExist() + } + + @Test + fun `clicking on version sends a PreferencesRootEvents`() { + val version = "VERSION" + val eventsRecorder = EventsRecorder() + rule.setView( + aPreferencesRootState( + version = version, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(version).performClick() + eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick) + } +} + +private fun AndroidComposeTestRule.setView( + state: PreferencesRootState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onAddAccountClick: () -> Unit = EnsureNeverCalled(), + onSecureBackupClick: () -> Unit = EnsureNeverCalled(), + onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(), + onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(), + onOpenAnalytics: () -> Unit = EnsureNeverCalled(), + onOpenRageShake: () -> Unit = EnsureNeverCalled(), + onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), + onOpenAbout: () -> Unit = EnsureNeverCalled(), + onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(), + onOpenAdvancedSettings: () -> Unit = EnsureNeverCalled(), + onOpenLabs: () -> Unit = EnsureNeverCalled(), + onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), + onOpenUserProfile: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), + onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(), + onSignOutClick: () -> Unit = EnsureNeverCalled(), + onDeactivateClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + PreferencesRootView( + state = state, + onBackClick = onBackClick, + onAddAccountClick = onAddAccountClick, + onSecureBackupClick = onSecureBackupClick, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + onOpenAnalytics = onOpenAnalytics, + onOpenRageShake = onOpenRageShake, + onOpenLockScreenSettings = onOpenLockScreenSettings, + onOpenAbout = onOpenAbout, + onOpenDeveloperSettings = onOpenDeveloperSettings, + onOpenAdvancedSettings = onOpenAdvancedSettings, + onOpenLabs = onOpenLabs, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenUserProfile = onOpenUserProfile, + onOpenBlockedUsers = onOpenBlockedUsers, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + } +} 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 b1e3356fc3..660b071983 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 @@ -24,7 +24,7 @@ enum class AvatarSize(val dp: Dp) { RoomSelectRoomListItem(36.dp), - UserPreference(56.dp), + UserPreference(52.dp), UserHeader(96.dp), UserListItem(36.dp), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewDark.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewDark.kt index c054b318f3..4d2db383a7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewDark.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewDark.kt @@ -8,16 +8,21 @@ package io.element.android.libraries.designsystem.preview +import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable +import io.element.android.libraries.designsystem.utils.CommonDrawables @Composable fun ElementPreviewDark( showBackground: Boolean = true, - content: @Composable () -> Unit + @DrawableRes + drawableFallbackForImages: Int = CommonDrawables.sample_background, + content: @Composable () -> Unit, ) { ElementPreview( darkTheme = true, showBackground = showBackground, - content = content + drawableFallbackForImages = drawableFallbackForImages, + content = content, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt index 1c2bdf3cef..19b40f3520 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt @@ -8,16 +8,21 @@ package io.element.android.libraries.designsystem.preview +import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable +import io.element.android.libraries.designsystem.utils.CommonDrawables @Composable fun ElementPreviewLight( showBackground: Boolean = true, - content: @Composable () -> Unit + @DrawableRes + drawableFallbackForImages: Int = CommonDrawables.sample_background, + content: @Composable () -> Unit, ) { ElementPreview( darkTheme = false, showBackground = showBackground, - content = content + drawableFallbackForImages = drawableFallbackForImages, + content = content, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt index 5b44b50ce9..0b9bde0fb9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.ui.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,51 +35,34 @@ import io.element.android.libraries.matrix.ui.model.getBestName @Composable fun MatrixUserHeader( - matrixUser: MatrixUser?, - modifier: Modifier = Modifier, - // TODO handle click on this item, to let the user be able to update their profile. - // onClick: () -> Unit, -) { - if (matrixUser == null) { - MatrixUserHeaderPlaceholder(modifier = modifier) - } else { - MatrixUserHeaderContent( - matrixUser = matrixUser, - modifier = modifier, - // onClick = onClick - ) - } -} - -@Composable -private fun MatrixUserHeaderContent( matrixUser: MatrixUser, modifier: Modifier = Modifier, - // onClick: () -> Unit, ) { Row( modifier = modifier - // .clickable(onClick = onClick) .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { Avatar( modifier = Modifier - .padding(vertical = 12.dp), + .padding(vertical = 7.dp), avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference), avatarType = AvatarType.User, ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(13.dp)) Column( - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { // Name Text( modifier = Modifier.clipToBounds(), text = matrixUser.getBestName(), maxLines = 1, - style = ElementTheme.typography.fontHeadingSmMedium, + style = ElementTheme.typography.fontHeadingMdRegular, overflow = TextOverflow.Ellipsis, color = ElementTheme.colors.textPrimary, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt deleted file mode 100644 index 08f8a37cc1..0000000000 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt +++ /dev/null @@ -1,64 +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.libraries.matrix.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.placeholderBackground - -@Composable -fun MatrixUserHeaderPlaceholder( - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .padding(vertical = 12.dp) - .size(AvatarSize.UserPreference.dp) - .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column( - modifier = Modifier.weight(1f) - ) { - PlaceholderAtom(width = 80.dp, height = 7.dp) - Spacer(modifier = Modifier.height(16.dp)) - PlaceholderAtom(width = 180.dp, height = 6.dp) - } - } -} - -@PreviewsDayNight -@Composable -internal fun MatrixUserHeaderPlaceholderPreview() = ElementPreview { - MatrixUserHeaderPlaceholder() -} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt index 4d5a1cd222..5e9f29496e 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -20,15 +20,6 @@ open class MatrixUserProvider : PreviewParameterProvider { ) } -open class MatrixUserWithNullProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aMatrixUser(), - aMatrixUser(displayName = null), - null, - ) -} - open class MatrixUserWithAvatarProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt index cf89074737..ed7fd63435 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.ui.components import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -23,12 +25,14 @@ fun MatrixUserRow( matrixUser: MatrixUser, modifier: Modifier = Modifier, avatarSize: AvatarSize = AvatarSize.UserListItem, + verticalSpaceWidth: Dp = 12.dp, trailingContent: @Composable (() -> Unit)? = null, ) = UserRow( avatarData = matrixUser.getAvatarData(avatarSize), name = matrixUser.getBestName(), subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, modifier = modifier, + verticalSpaceWidth = verticalSpaceWidth, trailingContent = trailingContent, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt index 9bcf0b323f..8d236d1a2a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt @@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -31,22 +34,22 @@ internal fun UserRow( subtext: String?, modifier: Modifier = Modifier, enabled: Boolean = true, + verticalSpaceWidth: Dp = 12.dp, trailingContent: @Composable (() -> Unit)? = null, ) { Row( modifier = modifier .fillMaxWidth() - .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Avatar( avatarData = avatarData, avatarType = AvatarType.User, ) + Spacer(modifier = Modifier.width(verticalSpaceWidth)) Column( - modifier = Modifier - .padding(start = 12.dp) - .weight(1f), + modifier = Modifier.weight(1f), ) { // Name Text( From efe76281ad96f4c577672ce93a0a5930bca59db8 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 16 Apr 2026 14:32:48 +0000 Subject: [PATCH 52/53] Update screenshots --- ...res.preferences.impl.root_MultiAccountSection_Day_0_en.png | 3 --- ...s.preferences.impl.root_MultiAccountSection_Night_0_en.png | 3 --- ...res.preferences.impl.root_PreferencesRootViewDark_0_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_1_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_2_en.png | 3 +++ ...res.preferences.impl.root_PreferencesRootViewDark_3_en.png | 3 +++ ...res.preferences.impl.root_PreferencesRootViewDark_4_en.png | 3 +++ ...res.preferences.impl.root_PreferencesRootViewDark_5_en.png | 3 +++ ...es.preferences.impl.root_PreferencesRootViewLight_0_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_1_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_2_en.png | 3 +++ ...es.preferences.impl.root_PreferencesRootViewLight_3_en.png | 3 +++ ...es.preferences.impl.root_PreferencesRootViewLight_4_en.png | 3 +++ ...es.preferences.impl.root_PreferencesRootViewLight_5_en.png | 3 +++ ...eatures.preferences.impl.user_UserPreferences_Day_0_en.png | 4 ++-- ...eatures.preferences.impl.user_UserPreferences_Day_1_en.png | 4 ++-- ...eatures.preferences.impl.user_UserPreferences_Day_2_en.png | 3 --- ...tures.preferences.impl.user_UserPreferences_Night_0_en.png | 4 ++-- ...tures.preferences.impl.user_UserPreferences_Night_1_en.png | 4 ++-- ...tures.preferences.impl.user_UserPreferences_Night_2_en.png | 3 --- ...rix.ui.components_MatrixUserHeaderPlaceholder_Day_0_en.png | 3 --- ...x.ui.components_MatrixUserHeaderPlaceholder_Night_0_en.png | 3 --- ...braries.matrix.ui.components_MatrixUserHeader_Day_0_en.png | 4 ++-- ...braries.matrix.ui.components_MatrixUserHeader_Day_1_en.png | 4 ++-- ...aries.matrix.ui.components_MatrixUserHeader_Night_0_en.png | 4 ++-- ...aries.matrix.ui.components_MatrixUserHeader_Night_1_en.png | 4 ++-- 26 files changed, 48 insertions(+), 42 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png deleted file mode 100644 index 29b7fa324c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:df19ce5a967143e2cb6d1fe021663f72e36f20c32e912894a2fbad628f03c3e5 -size 53561 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png deleted file mode 100644 index 2ba5234dc6..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8fd24e865907b5c9240829710a910e445954bef9b8575f5115a52837e00d817f -size 54591 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png index bc97d5a5c7..4a62c517f0 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:369c835c46e19d3e3171add57055624cf672a9d34109e6c831e0c1bce234c605 -size 39513 +oid sha256:712c1fca10ed7655634d300c03615c6c4dd2f71b74c178398d72fa0427f0d766 +size 41537 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png index c6b82b8385..3ee64a4d09 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d9f6763de5b844eeace37bedb25b125976625394d69d7843eedb26319e926aa -size 39316 +oid sha256:da47d339d9b8712aa13c394482f8aa5d2e1fb4fcb8eb10df473394bfec1ef507 +size 25980 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png new file mode 100644 index 0000000000..4a56ec8135 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:324ce7d935816d87fea7b70bc7ebaacb0ac1d007b08dba85f03c03a1f045e450 +size 36764 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png new file mode 100644 index 0000000000..f1b39a0d35 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9176ee2b5b78032639d9f51f7680d82f7d3ca916fb587d914f12d075382d65f0 +size 27077 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png new file mode 100644 index 0000000000..5e115162c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:821a13ed4effd98ad459e3697a33d9d42500d7f1f46115a97c9b7444303a3bb2 +size 27645 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png new file mode 100644 index 0000000000..31a44423ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e457b722fe403205f1394e7347dedb3aba308e48c979ea002399425c9a130fd2 +size 20667 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png index dccf28ff97..d2e319b028 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dce8486726293027aedcdd2e67d10a39a1a2c439ca67d81ae247b60119675ada -size 40385 +oid sha256:2c149288a8ef258f65292f673b9a15ea34910db6d3bfe2402b2a3264227f2b0d +size 42547 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png index 6f2381c6f4..cdd9ef1a41 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93ee581f59c79e03b9c9311765da4c828c5009d14e92f7cca9bbcee418fdfc63 -size 40442 +oid sha256:7ecf19446d8a0cf57431f13cbac9331ff72c93637c1cd1b442ed6330566debb2 +size 26879 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png new file mode 100644 index 0000000000..a83adbff85 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00c7c42f1e6dde16916ae12a889b728c0fb321aa2c3fc6af80ec01d99a3af7a6 +size 37164 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png new file mode 100644 index 0000000000..15c12f4836 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74a4913734d0648115d5056052fa2de8a839bb5a4d2dfdaa3d8ed5f0eef2793d +size 27728 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png new file mode 100644 index 0000000000..3346ad327d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6c20d715bfa287ca0fa78bab1166ff0370b5124455c87f1956e7dc3cb9b3d36 +size 28253 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png new file mode 100644 index 0000000000..c325a4e7b5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d5b500d6275bc6ead5e8644b1afb1a0a829e91d4912444e5e0d431322343855 +size 20700 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_0_en.png index 4c4d183956..c6e079dee2 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ccaa7f88a9e46bdc526bfe3d5c2163bf3d963c0661179db97f62559edfd3189 -size 11042 +oid sha256:0805bac4bf9e1c5bb16b4f81b004bcc952563eef98101d8c9c6e856a414977d0 +size 11219 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_1_en.png index 0edaae43b6..74bc9743d7 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f37e1587ba12f9b6326b5b7398982fc663ca913da8c0ee83dfbd5e9decbd4362 -size 10906 +oid sha256:b61c5a72e8e63a1775d20717c78d8e68e46c7c22b8e4ab8c24154dc3d48d7d0e +size 11428 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_2_en.png deleted file mode 100644 index 3a7abad03f..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b494ecdb4962772dd548339a4ac57be40b273b513697ed6d039d1c905617d54f -size 4987 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_0_en.png index 741a708fe7..f21d99fffc 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bcb0063babe7091368af6b5bc7e8929c54ea879bd78043d9128db2dcea9d79fa -size 11191 +oid sha256:070163694fe10b465f2903ce2cc88f9ffc67d9489aa5a4e204cd087fd02b8642 +size 11281 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_1_en.png index c4e8dfdd29..9413b91e88 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f34e63a88464ddc817d1ffe0324352199ae1821dfa846cceac129a656ece2eb6 -size 10911 +oid sha256:da4e1512cd7a58ede774755b6e3ac427e90c437bbbadaea8479506af776999c3 +size 11323 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_2_en.png deleted file mode 100644 index 17d1ff9d1b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user_UserPreferences_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f0618a9f769e15b4e682d763224cc1fe0abf62c58f3b9a6b4059153f8805671e -size 4740 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Day_0_en.png deleted file mode 100644 index 3a7abad03f..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b494ecdb4962772dd548339a4ac57be40b273b513697ed6d039d1c905617d54f -size 4987 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Night_0_en.png deleted file mode 100644 index 17d1ff9d1b..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeaderPlaceholder_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f0618a9f769e15b4e682d763224cc1fe0abf62c58f3b9a6b4059153f8805671e -size 4740 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_0_en.png index 4c4d183956..c6e079dee2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ccaa7f88a9e46bdc526bfe3d5c2163bf3d963c0661179db97f62559edfd3189 -size 11042 +oid sha256:0805bac4bf9e1c5bb16b4f81b004bcc952563eef98101d8c9c6e856a414977d0 +size 11219 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_1_en.png index 0edaae43b6..74bc9743d7 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f37e1587ba12f9b6326b5b7398982fc663ca913da8c0ee83dfbd5e9decbd4362 -size 10906 +oid sha256:b61c5a72e8e63a1775d20717c78d8e68e46c7c22b8e4ab8c24154dc3d48d7d0e +size 11428 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_0_en.png index 741a708fe7..f21d99fffc 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bcb0063babe7091368af6b5bc7e8929c54ea879bd78043d9128db2dcea9d79fa -size 11191 +oid sha256:070163694fe10b465f2903ce2cc88f9ffc67d9489aa5a4e204cd087fd02b8642 +size 11281 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_1_en.png index c4e8dfdd29..9413b91e88 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_MatrixUserHeader_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f34e63a88464ddc817d1ffe0324352199ae1821dfa846cceac129a656ece2eb6 -size 10911 +oid sha256:da4e1512cd7a58ede774755b6e3ac427e90c437bbbadaea8479506af776999c3 +size 11323 From 6a4fed2baf4c072e6156901db287e1db593cb1b5 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:30:21 +0300 Subject: [PATCH 53/53] Natural media viewer swiping order (#6431) --- .../impl/viewer/MediaViewerDataSource.kt | 23 ++++++++-- .../impl/viewer/MediaViewerPresenter.kt | 20 ++++++--- .../impl/viewer/MediaViewerPresenterTest.kt | 43 ++++++++++--------- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 928e5d9ca8..a9fb5d645c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -120,11 +120,25 @@ class MediaViewerDataSource( */ private fun buildMediaViewerPageList(groupedItems: List) = buildList { // Filter out DateSeparator items, we do not need them for the media viewer - val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator } - pagerKeysHandler.accept(groupedItemsNoDateSeparator) - groupedItemsNoDateSeparator.forEach { mediaItem -> + val itemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator } + // Separate loading indicators and media events + val loadingIndicators = itemsNoDateSeparator.filterIsInstance() + val mediaEvents = itemsNoDateSeparator.filterIsInstance() + // Determine backward and forward loading indicators + val backwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.BACKWARDS } + val forwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.FORWARDS } + // Build ordered list: backward loading, media events (oldest first), forward loading + // Media events are currently newest first, reverse to get oldest first + val orderedEvents = mediaEvents.reversed() + // Create new list of MediaItem in order: backwardLoading, orderedEvents, forwardLoading + val orderedItems = buildList { + backwardLoading?.let { add(it) } + addAll(orderedEvents) + forwardLoading?.let { add(it) } + } + pagerKeysHandler.accept(orderedItems) + orderedItems.forEach { mediaItem -> when (mediaItem) { - is MediaItem.DateSeparator -> Unit is MediaItem.Event -> { val sourceUrl = mediaItem.mediaSource().safeUrl val localMedia = localMediaStates.getOrPut(sourceUrl) { @@ -148,6 +162,7 @@ class MediaViewerDataSource( pagerKey = pagerKeysHandler.getKey(mediaItem), ) ) + is MediaItem.DateSeparator -> Unit // already filtered out } } }.toImmutableList() diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index dc0feb70cf..ae581fa8d4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -179,15 +179,19 @@ class MediaViewerPresenter( ) { val isRenderingLoadingBackward by remember { derivedStateOf { - currentIndex.intValue == data.value.lastIndex && + currentIndex.intValue == 0 && data.value.size > 1 && - data.value.lastOrNull() is MediaViewerPageData.Loading + data.value.firstOrNull() is MediaViewerPageData.Loading && + (data.value.firstOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.BACKWARDS } } if (isRenderingLoadingBackward) { LaunchedEffect(Unit) { // Observe the loading data vanishing - snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading } + snapshotFlow { + val first = data.value.firstOrNull() + first is MediaViewerPageData.Loading && first.direction == Timeline.PaginationDirection.BACKWARDS + } .distinctUntilChanged() .filter { !it } .onEach { showNoMoreItemsSnackbar() } @@ -203,15 +207,19 @@ class MediaViewerPresenter( ) { val isRenderingLoadingForward by remember { derivedStateOf { - currentIndex.intValue == 0 && + currentIndex.intValue == data.value.lastIndex && data.value.size > 1 && - data.value.firstOrNull() is MediaViewerPageData.Loading + data.value.lastOrNull() is MediaViewerPageData.Loading && + (data.value.lastOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.FORWARDS } } if (isRenderingLoadingForward) { LaunchedEffect(Unit) { // Observe the loading data vanishing - snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading } + snapshotFlow { + val last = data.value.lastOrNull() + last is MediaViewerPageData.Loading && last.direction == Timeline.PaginationDirection.FORWARDS + } .distinctUntilChanged() .filter { !it } .onEach { showNoMoreItemsSnackbar() } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index a9d1704bdc..c217ea3306 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -593,20 +593,20 @@ class MediaViewerPresenterTest { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { GroupedMediaItems( imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator), ) } else { GroupedMediaItems( - imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator), fileItems = persistentListOf(), ) } ) ) val updatedState = awaitItem() - // User navigate to the first item (forward loading indicator) + // User navigate to the last item (forward loading indicator) updatedState.eventSink( - MediaViewerEvents.OnNavigateTo(0) + MediaViewerEvents.OnNavigateTo(2) ) // data source claims that there is no more items to load forward mediaGalleryDataSource.emitGroupedMediaItems( @@ -614,19 +614,21 @@ class MediaViewerPresenterTest { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { GroupedMediaItems( imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf(anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(aBackwardLoadingIndicator, anImage), ) } else { GroupedMediaItems( - imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator), + imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage), fileItems = persistentListOf(), ) } ) ) - skipItems(1) - val stateWithSnackbar = awaitItem() - assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) + var stateWithSnackbar = awaitItem() + while (stateWithSnackbar.snackbarMessage == null) { + stateWithSnackbar = awaitItem() + } + assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId) } } @@ -665,41 +667,42 @@ class MediaViewerPresenterTest { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { GroupedMediaItems( imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator), ) } else { GroupedMediaItems( - imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator), fileItems = persistentListOf(), ) } ) ) val updatedState = awaitItem() - // User navigate to the last item (backward loading indicator) + // User navigate to the first item (backward loading indicator) updatedState.eventSink( - MediaViewerEvents.OnNavigateTo(2) + MediaViewerEvents.OnNavigateTo(0) ) - skipItems(1) // data source claims that there is no more items to load backward mediaGalleryDataSource.emitGroupedMediaItems( AsyncData.Success( if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { GroupedMediaItems( imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf(aForwardLoadingIndicator, anImage), + fileItems = persistentListOf(anImage, aForwardLoadingIndicator), ) } else { GroupedMediaItems( - imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage), + imageAndVideoItems = persistentListOf(anImage, aForwardLoadingIndicator), fileItems = persistentListOf(), ) } ) ) - skipItems(1) - val stateWithSnackbar = awaitItem() - assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) + var stateWithSnackbar = awaitItem() + while (stateWithSnackbar.snackbarMessage == null) { + stateWithSnackbar = awaitItem() + } + assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId) } } @@ -717,7 +720,7 @@ class MediaViewerPresenterTest { mediaGalleryDataSource.emitGroupedMediaItems( AsyncData.Success( GroupedMediaItems( - imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator), fileItems = persistentListOf(), ) )