Merge branch 'develop' into feature-oled-black
This commit is contained in:
commit
4e5542396f
319 changed files with 8286 additions and 2172 deletions
2
.github/workflows/generate_github_pages.yml
vendored
2
.github/workflows/generate_github_pages.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/recordScreenshots.yml
vendored
4
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/validate-lfs.yml
vendored
2
.github/workflows/validate-lfs.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
59
CHANGES.md
59
CHANGES.md
|
|
@ -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
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/202604030.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202604030.txt
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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 you’ve joined",
|
||||
iconVector = CompoundIcons.Leave(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Announcement.Fullscreen.notice(): String? = when (this) {
|
||||
Announcement.Fullscreen.Space -> "Filtering, creating and managing spaces is coming soon."
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun FullscreenAnnouncementViewPreview(@PreviewParameter(AnnouncementStateProvider::class) state: AnnouncementState) = ElementPreview {
|
||||
FullscreenAnnouncementView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ internal fun createRoomListRoomSummary(
|
|||
displayType = displayType,
|
||||
userDefinedNotificationMode = userDefinedNotificationMode,
|
||||
hasRoomCall = false,
|
||||
activeCallIntent = null,
|
||||
isDirect = false,
|
||||
isFavorite = isFavorite,
|
||||
canonicalAlias = null,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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—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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter(
|
|||
loginHelper.submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = it.url,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ class ConfirmAccountProviderPresenter(
|
|||
loginHelper.submit(
|
||||
isAccountCreation = params.isAccountCreation,
|
||||
homeserverUrl = accountProvider.url,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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—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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 |
|
|
@ -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 > Security & 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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue