Merge branch 'develop' into feature-oled-black

This commit is contained in:
Benoit Marty 2026-04-17 14:47:15 +02:00 committed by GitHub
commit 4e5542396f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
319 changed files with 8286 additions and 2172 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,3 +1,62 @@
Changes in Element X v26.04.3
=============================
<!-- Release notes generated using configuration in .github/release.yml at 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
=============================

View file

@ -252,7 +252,8 @@ class RootFlowNode(
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
when (navTarget) {
is NavTarget.SplashScreen,
is NavTarget.LoggedInFlow -> backstackFader
is NavTarget.LoggedInFlow,
is NavTarget.NotLoggedInFlow -> backstackFader
else -> backstackSlider
}
}

View file

@ -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
@ -29,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
@ -56,6 +57,7 @@ class LoggedInPresenter(
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
) : Presenter<LoggedInState> {
@Composable
override fun present(): LoggedInState {
@ -107,6 +109,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 -> {
@ -166,7 +176,6 @@ class LoggedInPresenter(
}
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList)
matrixClient.getAccountManagementUrl(null)
}
}

View file

@ -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
@ -68,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<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = accountManagementUrlResult,
@ -78,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))
}
}
@ -109,6 +109,7 @@ class LoggedInPresenterTest {
val verificationService = FakeSessionVerificationService()
val encryptionService = FakeEncryptionService()
val buildMeta = aBuildMeta()
val networkMonitor = FakeNetworkMonitor()
LoggedInPresenter(
matrixClient = FakeMatrixClient(
roomListService = roomListService,
@ -122,6 +123,7 @@ class LoggedInPresenterTest {
analyticsService = analyticsService,
encryptionService = encryptionService,
buildMeta = buildMeta,
networkMonitor = networkMonitor,
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
@ -319,6 +321,27 @@ class LoggedInPresenterTest {
}
}
@Test
fun `present - refreshes homeserver capabilities when network is back`() = runTest {
val refreshLambda = lambdaRecorder<Result<Unit>> { 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 <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
@ -334,6 +357,7 @@ class LoggedInPresenterTest {
accountManagementUrlResult = { Result.success(null) },
),
buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
@ -343,6 +367,7 @@ class LoggedInPresenterTest {
analyticsService = analyticsService,
encryptionService = encryptionService,
buildMeta = buildMeta,
networkMonitor = networkMonitor,
)
}
}

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -8,7 +8,13 @@
package io.element.android.features.announcement.api
enum class Announcement {
Space,
NewNotificationSound,
import androidx.compose.runtime.Immutable
@Immutable
sealed interface Announcement {
enum class Fullscreen : Announcement {
Space,
}
data object NewNotificationSound : Announcement
}

View file

@ -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
}

View file

@ -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<AnnouncementState> {
@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,
)
}
}

View file

@ -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?,
val eventSink: (AnnouncementEvent) -> Unit,
)

View file

@ -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<AnnouncementState> {
override val values: Sequence<AnnouncementState>
get() = sequenceOf(
anAnnouncementState(),
anAnnouncementState(
announcement = Announcement.Fullscreen.Space,
),
)
}
fun anAnnouncementState(
announcement: Announcement.Fullscreen? = null,
eventSink: (AnnouncementEvent) -> Unit = {},
) = AnnouncementState(
announcement = announcement,
eventSink = eventSink,
)

View file

@ -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<AnnouncementState>,
private val spaceAnnouncementPresenter: Presenter<SpaceAnnouncementState>,
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<List<Announcement>> {
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,
)
}
}

View file

@ -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<AnnouncementState>
@Binds
fun bindSpaceAnnouncementPresenter(presenter: SpaceAnnouncementPresenter): Presenter<SpaceAnnouncementState>
}

View file

@ -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<Announcement.Fullscreen?>(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<InfoListItem> = 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 youve 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,
)
}

View file

@ -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
}

View file

@ -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<SpaceAnnouncementState> {
@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,
)
}
}

View file

@ -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
)

View file

@ -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<SpaceAnnouncementState> {
override val values: Sequence<SpaceAnnouncementState>
get() = sequenceOf(
aSpaceAnnouncementState(),
)
}
fun aSpaceAnnouncementState(
eventSink: (SpaceAnnouncementEvents) -> Unit = {},
) = SpaceAnnouncementState(
eventSink = eventSink,
)

View file

@ -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,
)
}

View file

@ -35,9 +35,10 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
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 +53,6 @@ class DefaultAnnouncementStore(
}
private fun Announcement.toKey() = when (this) {
Announcement.Space -> spaceAnnouncementKey
Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey
}

View file

@ -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
@ -23,25 +24,47 @@ 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()
}
}
@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()
}
}
}

View file

@ -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<AnnouncementState> = Presenter { anAnnouncementState() },
spaceAnnouncementPresenter: Presenter<SpaceAnnouncementState> = Presenter { aSpaceAnnouncementState() },
announcementPresenter: AnnouncementPresenter = AnnouncementPresenter(announcementStore),
) = DefaultAnnouncementService(
announcementStore = announcementStore,
announcementPresenter = announcementPresenter,
spaceAnnouncementPresenter = spaceAnnouncementPresenter,
)
}

View file

@ -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<ComponentActivity>()
@Test
fun `clicking on back sends a SpaceAnnouncementEvents`() {
val eventsRecorder = EventsRecorder<SpaceAnnouncementEvents>()
rule.setSpaceAnnouncementView(
aSpaceAnnouncementState(
fun `clicking on back sends a AnnouncementEvent`() {
val eventsRecorder = EventsRecorder<AnnouncementEvent>()
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<SpaceAnnouncementEvents>()
rule.setSpaceAnnouncementView(
aSpaceAnnouncementState(
fun `clicking on Continue sends a AnnouncementEvent`() {
val eventsRecorder = EventsRecorder<AnnouncementEvent>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceAnnouncementView(
state: SpaceAnnouncementState,
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setFullscreenAnnouncementView(
state: AnnouncementState,
) {
setContent {
SpaceAnnouncementView(
FullscreenAnnouncementView(
state = state,
)
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
) : Presenter<HomeState> {
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 {

View file

@ -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,
)

View file

@ -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(),

View file

@ -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,

View file

@ -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<RoomListRoomSu
listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
),
listOf(
aRoomListRoomSummary(
name = "Active voice call",
latestEvent = LatestEvent.Synced("No activity, call"),
hasRoomCall = true,
activeCallIntent = CallIntent.AUDIO
),
)
).flatten()
}
@ -158,6 +167,7 @@ internal fun aRoomListRoomSummary(
timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
activeCallIntent: CallIntent? = null,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDirect: Boolean = false,
isDm: Boolean = false,
@ -181,6 +191,7 @@ internal fun aRoomListRoomSummary(
avatarData = avatarData,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
activeCallIntent = activeCallIntent,
isDirect = isDirect,
isDm = isDm,
isFavorite = isFavorite,

View file

@ -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<Announcement, Unit> { }
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<HomeSpacesState> = 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,
)

View file

@ -101,6 +101,7 @@ internal fun createRoomListRoomSummary(
displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false,
activeCallIntent = null,
isDirect = false,
isFavorite = isFavorite,
canonicalAlias = null,

View file

@ -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)
}

View file

@ -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<MatrixUser>
) : AsyncAction.Confirming

View file

@ -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
}

View file

@ -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<Unit>>(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<MatrixUser, IdentityState?>().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 -> {

View file

@ -76,6 +76,16 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
selectedUsers = aMatrixUserList().toImmutableList(),
sendInvitesAction = AsyncAction.Loading,
),
aDefaultInvitePeopleState(
sendInvitesAction = ConfirmingUnknownUserInvitation(persistentListOf(
aMatrixUser("@alice:server.org")
))
),
aDefaultInvitePeopleState(
sendInvitesAction = ConfirmingUnknownUserInvitation(
aMatrixUserList().toImmutableList()
)
),
)
}

View file

@ -16,30 +16,42 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
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.invitepeople.api.InvitePeopleEvents
import io.element.android.libraries.architecture.AsyncData
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.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
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.components.Button
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.ListSectionHeader
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.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@ -143,6 +155,15 @@ private fun InvitePeopleContentView(
}
}
}
if (state.sendInvitesAction is ConfirmingUnknownUserInvitation) {
InvitePeopleConfirmModal(
users = state.sendInvitesAction.users,
onDismiss = { state.eventSink.invoke(DefaultInvitePeopleEvents.DismissUnknownUsersModal) },
onInvite = { state.eventSink.invoke(InvitePeopleEvents.SendInvites) },
onRemove = { state.eventSink.invoke(DefaultInvitePeopleEvents.RemoveUnknownUsers) }
)
}
}
}
@ -230,6 +251,56 @@ private fun InvitePeopleSearchBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InvitePeopleConfirmModal(
users: ImmutableList<MatrixUser>,
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) =

View file

@ -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, Result<IdentityState?>> { 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, Result<Unit>> { 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, Result<IdentityState?>> { 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, Result<IdentityState?>> { 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<MatrixUser>,
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,
)
}

View file

@ -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)
@ -79,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)

View file

@ -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,14 +31,17 @@ 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
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
@ -63,9 +67,11 @@ class LoginFlowNode(
private val oidcActionFlow: OidcActionFlow,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val elementClassicConnection: ElementClassicConnection,
private val preferencesEntryPoint: PreferencesEntryPoint,
) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.OnBoarding,
initialElement = NavTarget.CheckClassicFlow,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@ -103,11 +109,19 @@ 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
@Parcelize
data object AppDeveloperSettings : NavTarget
@Parcelize
data class ConfirmAccountProvider(
val isAccountCreation: Boolean,
@ -123,7 +137,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 +147,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<ClassicFlowNode>(buildContext, listOf(callback))
}
is NavTarget.OnBoarding -> {
val callback = object : OnBoardingNode.Callback {
override fun navigateToSignUpFlow() {
backstack.push(
@ -165,21 +205,42 @@ class LoginFlowNode(
backstack.push(NavTarget.CreateAccount(url))
}
override fun navigateToDeveloperSettings() {
backstack.push(NavTarget.AppDeveloperSettings)
}
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<Params>()
val inputs = OnBoardingNode.Params(
accountProvider = params.accountProvider,
loginHint = params.loginHint,
showBackButton = navTarget.showBackButton,
)
createNode<OnBoardingNode>(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) {
@ -191,7 +252,7 @@ class LoginFlowNode(
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
backstack.push(NavTarget.LoginPassword())
}
}
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
@ -218,7 +279,7 @@ class LoginFlowNode(
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
backstack.push(NavTarget.LoginPassword())
}
override fun navigateToChangeAccountProvider() {
@ -257,8 +318,11 @@ class LoginFlowNode(
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext)
is NavTarget.LoginPassword -> {
val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin,
)
createNode<LoginPasswordNode>(buildContext, plugins = listOf(inputs))
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
@ -280,6 +344,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 +360,6 @@ class LoginFlowNode(
}
}
}
BackstackView()
BackstackView(transitionHandler = rememberLoginFlowTransitionHandler())
}
}

View file

@ -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<LoginFlowNode.NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>,
) : ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<LoginFlowNode.NavTarget, BackStack.State>
): 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<LoginFlowNode.NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<LoginFlowNode.NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<LoginFlowNode.NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
LoginFlowTransitionHandler(slider, fader)
}
}

View file

@ -0,0 +1,423 @@
/*
* 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.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
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()
val stateFlow: StateFlow<ElementClassicConnectionState>
}
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,
private val featureFlagService: FeatureFlagService,
) : 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
private val mutableStateFlow = MutableStateFlow<ElementClassicConnectionState>(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.
*/
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&mdash;that is, its process crashed.
messenger = null
bound = false
}
}
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.
try {
val intentService = Intent()
intentService.setComponent(elementClassicComponent)
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).d("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).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")
// Do not emit error, else the regular on boarding flow will be displayed
} 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()))
}
}
}
}
private fun requestAvatar(userId: UserId) {
Timber.tag(loggerTag.value).d("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")
}
}
}
}
/**
* 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)
val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId
if (userId != null) {
// Step 2, request the avatar
requestAvatar(userId)
}
}
}
@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)
// 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 {
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).d("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 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 {
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 &&
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)
}
)
// 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,
homeserverUrl = homeserverUrl,
secrets = secrets,
roomKeysVersion = roomKeysVersion,
doesContainBackupKey = doesContainBackupKey,
),
displayName = displayName,
avatar = currentAvatar,
)
}
}
}
// 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"
}
}

View file

@ -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<ChangeServerState>
@Binds
fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter<LoginWithClassicState>
}

View file

@ -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

View file

@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter(
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = it.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
}

View file

@ -0,0 +1,149 @@
/*
* 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.lifecycle.subscribe
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<Plugin>,
private val classicFlowNodeHelper: ClassicFlowNodeHelper,
) : BaseFlowNode<ClassicFlowNode.NavTarget>(
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()
lifecycle.subscribe(
onResume = {
classicFlowNodeHelper.onResume()
},
)
}
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<RootNode>(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<LoginWithClassicNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.MissingKeyBackup -> {
val callback = object : MissingKeyBackupNode.Callback {
override fun navigateBack() {
backstack.pop()
}
}
createNode<MissingKeyBackupNode>(buildContext, listOf(callback))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(
modifier = modifier,
transitionHandler = rememberFaderOrSliderTransitionHandler(),
)
}
}

View file

@ -0,0 +1,75 @@
/*
* 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@Inject
class ClassicFlowNodeHelper(
private val elementClassicConnection: ElementClassicConnection,
private val sessionStore: SessionStore,
) {
fun onResume() {
elementClassicConnection.requestSession()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun navigationEventFlow(): Flow<NavigationEvent> {
return elementClassicConnection.stateFlow
.distinctUntilChangedBy {
// Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar
if (it is ElementClassicConnectionState.ElementClassicReady) {
it.copy(avatar = null)
} else {
it
}
}
.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))
}
}
}
}
}
}

View file

@ -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
}

View file

@ -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.loginwithclassic
sealed interface LoginWithClassicEvent {
data object Submit : LoginWithClassicEvent
data object ClearError : LoginWithClassicEvent
}

View file

@ -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()
}

View file

@ -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<Plugin>,
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,
)
}
}

View file

@ -0,0 +1,102 @@
/*
* 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<LoginWithClassicState> {
@AssistedFactory
interface Factory {
fun create(
userId: UserId,
navigator: LoginWithClassicNavigator,
): LoginWithClassicPresenter
}
@Composable
override fun present(): LoginWithClassicState {
val coroutineScope = rememberCoroutineScope()
var loginWithClassicAction by remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val loginMode by loginHelper.collectLoginMode()
val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState()
fun handleEvent(event: LoginWithClassicEvent) {
when (event) {
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,
)
}
}

View file

@ -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<Unit>,
val loginMode: AsyncData<LoginMode>,
val eventSink: (LoginWithClassicEvent) -> Unit,
)

View file

@ -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<LoginWithClassicState> {
override val values: Sequence<LoginWithClassicState>
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<Unit> = AsyncAction.Uninitialized,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (LoginWithClassicEvent) -> Unit = {},
) = LoginWithClassicState(
isElementPro = isElementPro,
userId = userId,
displayName = displayName,
avatar = avatar,
loginWithClassicAction = loginWithClassicAction,
loginMode = loginMode,
eventSink = eventSink,
)

View file

@ -0,0 +1,220 @@
/*
* 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 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,
) {
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 = {},
)
}

View file

@ -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<Plugin>,
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,
)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.runtime.Composable
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@Inject
class MissingKeyBackupPresenter(
private val buildMeta: BuildMeta,
) : Presenter<MissingKeyBackupState> {
@Composable
override fun present(): MissingKeyBackupState {
return MissingKeyBackupState(
appName = buildMeta.applicationName,
)
}
}

View file

@ -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
data class MissingKeyBackupState(
val appName: String,
)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class MissingKeyBackupStateProvider : PreviewParameterProvider<MissingKeyBackupState> {
override val values: Sequence<MissingKeyBackupState>
get() = sequenceOf(
aMissingKeyBackupState(),
// Add other state here
)
}
fun aMissingKeyBackupState(
appName: String = "AppName",
) = MissingKeyBackupState(
appName = appName,
)

View file

@ -0,0 +1,85 @@
/*
* 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 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 kotlinx.collections.immutable.persistentListOf
@Composable
fun MissingKeyBackupView(
state: MissingKeyBackupState,
onBackClick: () -> Unit,
onOpenClassicClick: () -> Unit,
modifier: Modifier = Modifier,
) {
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 = {},
)
}

View file

@ -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<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
RootView(modifier)
}
}

View file

@ -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()
}

View file

@ -48,6 +48,7 @@ class ConfirmAccountProviderPresenter(
loginHelper.submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
}

View file

@ -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<Plugin>,
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()

View file

@ -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<LoginPasswordState> {
@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()

View file

@ -42,12 +42,14 @@ class OnBoardingNode(
fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String)
fun navigateToDeveloperSettings()
fun onDone()
}
data class Params(
val accountProvider: String?,
val loginHint: String?,
val showBackButton: Boolean,
) : NodeInputs
private val callback: Callback = callback()
@ -61,6 +63,7 @@ class OnBoardingNode(
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
OnBoardingView(
state = state,
modifier = modifier,
@ -73,6 +76,7 @@ class OnBoardingNode(
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
onBackClick = callback::onDone,
onDeveloperSettingsClick = callback::navigateToDeveloperSettings,
)
}
}

View file

@ -26,10 +26,10 @@ 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
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
@ -45,7 +45,6 @@ class OnBoardingPresenter(
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginWithClassicPresenter: Presenter<LoginWithClassicState>,
) : Presenter<OnBoardingState> {
@AssistedFactory
interface Factory {
@ -101,8 +100,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 +108,7 @@ class OnBoardingPresenter(
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
}
@ -127,6 +125,8 @@ class OnBoardingPresenter(
return OnBoardingState(
isAddingAccount = isAddingAccount,
showBackButton = params.showBackButton,
showDeveloperSettings = buildMeta.buildType != BuildType.RELEASE,
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
@ -136,7 +136,6 @@ class OnBoardingPresenter(
loginMode = loginMode,
version = buildMeta.versionName,
onBoardingLogoResId = onBoardingLogoResId,
loginWithClassicState = loginWithClassicState,
eventSink = ::handleEvent,
)
}

View file

@ -10,11 +10,12 @@ 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 showDeveloperSettings: Boolean,
val productionApplicationName: String,
val defaultAccountProvider: String?,
val mustChooseAccountProvider: Boolean,
@ -25,7 +26,6 @@ data class OnBoardingState(
@DrawableRes
val onBoardingLogoResId: Int?,
val loginMode: AsyncData<LoginMode>,
val loginWithClassicState: LoginWithClassicState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean

View file

@ -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,17 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
canLoginWithQrCode = true,
canCreateAccount = true,
),
anOnBoardingState(
showBackButton = true,
showDeveloperSettings = true,
),
)
}
fun anOnBoardingState(
isAddingAccount: Boolean = false,
showBackButton: Boolean = false,
showDeveloperSettings: Boolean = false,
productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
mustChooseAccountProvider: Boolean = false,
@ -46,10 +50,11 @@ fun anOnBoardingState(
@DrawableRes
customLogoResId: Int? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
isAddingAccount = isAddingAccount,
showBackButton = showBackButton,
showDeveloperSettings = showDeveloperSettings,
productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
@ -59,6 +64,5 @@ fun anOnBoardingState(
version = version,
loginMode = loginMode,
onBoardingLogoResId = customLogoResId,
loginWithClassicState = loginWithClassicState,
eventSink = eventSink,
)

View file

@ -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
@ -69,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,
@ -114,45 +110,10 @@ fun OnBoardingView(
state = state,
loginView = loginView,
buttons = buttons,
onBackClick = onBackClick,
onDeveloperSettingsClick = onDeveloperSettingsClick,
)
}
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 +121,49 @@ private fun AddFirstAccountScaffold(
state: OnBoardingState,
loginView: @Composable () -> Unit,
buttons: @Composable () -> Unit,
onBackClick: () -> Unit,
onDeveloperSettingsClick: () -> 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.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(
onClick = onBackClick,
modifier = Modifier
.align(Alignment.TopEnd),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel),
)
}
}
}
loginView()
},
@ -283,18 +275,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),
@ -369,6 +349,7 @@ internal fun OnBoardingViewPreview(
OnBoardingView(
state = state,
onBackClick = {},
onDeveloperSettingsClick = {},
onSignInWithQrCode = {},
onSignIn = {},
onCreateAccount = {},

View file

@ -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<ElementClassicConnectionState>
}
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&mdash;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 happen 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>(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"
}
}

View file

@ -1,15 +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
sealed interface LoginWithClassicEvent {
data object RefreshData : LoginWithClassicEvent
data object StartLoginWithClassic : LoginWithClassicEvent
data object DoLoginWithClassic : LoginWithClassicEvent
data object CloseDialog : LoginWithClassicEvent
}

View file

@ -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<LoginWithClassicState> {
@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<Unit>>(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,
)
}
}

View file

@ -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<Unit>,
val eventSink: (LoginWithClassicEvent) -> Unit,
)

View file

@ -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<Unit> = AsyncAction.Uninitialized,
eventSink: (LoginWithClassicEvent) -> Unit = {},
) = LoginWithClassicState(
canLoginWithClassic = canLoginWithClassic,
loginWithClassicAction = loginWithClassicAction,
eventSink = eventSink,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -37,11 +37,19 @@
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Open Element Classic"</string>
<string name="screen_missing_key_backup_step_1">"Open Element Classic on your device"</string>
<string name="screen_missing_key_backup_step_2_android">"Go to Settings &gt; Security &amp; Privacy"</string>
<string name="screen_missing_key_backup_step_3_android">"In Cryptography keys management, select Encrypted message recovery"</string>
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
<string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
<string name="screen_onboarding_sign_up">"Create account"</string>
<string name="screen_onboarding_welcome_back">"Welcome back"</string>
<string name="screen_onboarding_welcome_message">"Welcome to the fastest %1$s ever. Supercharged for speed and simplicity."</string>
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
<string name="screen_onboarding_welcome_title">"Be in your element"</string>

View file

@ -15,6 +15,8 @@ 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.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
@ -39,6 +41,8 @@ class DefaultLoginEntryPointTest {
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
oidcActionFlow = FakeOidcActionFlow(),
appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(),
preferencesEntryPoint = FakePreferencesEntryPoint(),
)
}
val callback = object : LoginEntryPoint.Callback {

View file

@ -0,0 +1,530 @@
/*
* 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.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
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<ElementClassicSession?, Unit> { }
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<ElementClassicSession?, Unit> { }
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 `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(
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 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(
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) }
),
isFeatureEnabled: Boolean = true,
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SignInWithClassic.key to isFeatureEnabled,
)
),
) = DefaultElementClassicConnection(
serviceBinder = serviceBinder,
coroutineScope = coroutineScope,
matrixAuthenticationService = matrixAuthenticationService,
homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker,
featureFlagService = featureFlagService,
)
}

View file

@ -5,7 +5,7 @@
* 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.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
@ -15,12 +15,12 @@ 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() },
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
) : ElementClassicConnection {
override fun start() = startResult()
override fun stop() = stopResult()
override fun requestData() = requestDataResult()
override fun requestSession() = requestSessionResult()
private val mutableStateFlow = MutableStateFlow(initialState)
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
suspend fun emitState(state: ElementClassicConnectionState) {

View file

@ -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()
}
}

View file

@ -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,
)

View file

@ -0,0 +1,287 @@
/*
* 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.advanceTimeBy
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 `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)
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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)
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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)
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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)
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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)
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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))
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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)
)
)
advanceTimeBy(10_000)
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))
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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))
advanceTimeBy(10_000)
expectNoEvents()
}
}
@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,
)
)
advanceTimeBy(10_000)
expectNoEvents()
}
}
}
private fun createHelper(
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
sessionStore: SessionStore = InMemorySessionStore(),
) = ClassicFlowNodeHelper(
elementClassicConnection = elementClassicConnection,
sessionStore = sessionStore,
)

View file

@ -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()
}
}

View file

@ -0,0 +1,263 @@
/*
* 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 - 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<Unit> { }
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<Unit> { }
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,
),
)

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import com.google.common.truth.Truth.assertThat
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.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)
}
}
}
private fun createPresenter(
buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME),
) = MissingKeyBackupPresenter(
buildMeta = buildMeta,
)

View file

@ -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,
)

View file

@ -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(

View file

@ -11,9 +11,11 @@ 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 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 +33,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<ComponentActivity>()
@ -44,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()
}
}
@ -83,21 +90,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<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
@ -114,21 +111,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<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
@ -190,6 +177,22 @@ class OnboardingViewTest {
}
}
@Test
fun `clicking on settings calls the developer settings callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(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<OnBoardingEvents>(expectEvents = false)
@ -253,6 +256,7 @@ class OnboardingViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(),
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onCreateAccount: () -> Unit = EnsureNeverCalled(),
@ -266,6 +270,7 @@ class OnboardingViewTest {
OnBoardingView(
state = state,
onBackClick = onBackClick,
onDeveloperSettingsClick = onDeveloperSettingsClick,
onSignInWithQrCode = onSignInWithQrCode,
onSignIn = onSignIn,
onCreateAccount = onCreateAccount,

View file

@ -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<Unit> {}
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<Unit> {}
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,
)

View file

@ -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<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
NavTarget.ThreadsList -> {
val callback = object : ThreadsListNode.Callback {
override fun openThread(threadId: ThreadId) {
backstack.push(NavTarget.Thread(threadId, focusedEventId = null))
}
}
createNode<ThreadsListNode>(buildContext, listOf(callback))
}
}
}

View file

@ -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,

View file

@ -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,
)
}

View file

@ -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. */

Some files were not shown because too many files have changed in this diff Show more