Merge branch 'develop' into feature-oled-black
This commit is contained in:
commit
6d4258e248
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
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: ⏬ Checkout with LFS
|
- 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
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: ⏬ Checkout with LFS
|
- 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
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
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
|
labels: Record-Screenshots
|
||||||
- name: ⏬ Checkout with LFS (PR)
|
- name: ⏬ Checkout with LFS (PR)
|
||||||
if: github.event.label.name == 'Record-Screenshots'
|
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:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||||
- name: ⏬ Checkout with LFS (Branch)
|
- name: ⏬ Checkout with LFS (Branch)
|
||||||
if: github.event_name == 'workflow_dispatch'
|
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:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: ☕️ Use JDK 21
|
- 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 /mnt/swapfile
|
||||||
sudo swapon --show
|
sudo swapon --show
|
||||||
- name: ⏬ Checkout with LFS
|
- 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:
|
with:
|
||||||
# Ensure we are building the branch and not the branch after being merged on develop
|
# Ensure we are building the branch and not the branch after being merged on develop
|
||||||
# https://github.com/actions/checkout/issues/881
|
# 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
|
runs-on: ubuntu-latest
|
||||||
name: Validate
|
name: Validate
|
||||||
steps:
|
steps:
|
||||||
- uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
|
- uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
./tools/git/validate_lfs.sh
|
./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
|
Changes in Element X v26.04.2
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,8 @@ class RootFlowNode(
|
||||||
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
|
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
|
||||||
when (navTarget) {
|
when (navTarget) {
|
||||||
is NavTarget.SplashScreen,
|
is NavTarget.SplashScreen,
|
||||||
is NavTarget.LoggedInFlow -> backstackFader
|
is NavTarget.LoggedInFlow,
|
||||||
|
is NavTarget.NotLoggedInFlow -> backstackFader
|
||||||
else -> backstackSlider
|
else -> backstackSlider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
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.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
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.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.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
|
|
@ -56,6 +57,7 @@ class LoggedInPresenter(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
) : Presenter<LoggedInState> {
|
) : Presenter<LoggedInState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoggedInState {
|
override fun present(): LoggedInState {
|
||||||
|
|
@ -107,6 +109,14 @@ class LoggedInPresenter(
|
||||||
}.launchIn(this)
|
}.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) {
|
fun handleEvent(event: LoggedInEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is LoggedInEvents.CloseErrorDialog -> {
|
is LoggedInEvents.CloseErrorDialog -> {
|
||||||
|
|
@ -166,7 +176,6 @@ class LoggedInPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
|
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
|
||||||
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
|
matrixClient.getAccountManagementUrl(null)
|
||||||
matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import app.cash.turbine.ReceiveTurbine
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
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.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
|
|
@ -68,7 +71,7 @@ class LoggedInPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
accountManagementUrlResult = accountManagementUrlResult,
|
accountManagementUrlResult = accountManagementUrlResult,
|
||||||
|
|
@ -78,11 +81,8 @@ class LoggedInPresenterTest {
|
||||||
).test {
|
).test {
|
||||||
awaitItem()
|
awaitItem()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
accountManagementUrlResult.assertions().isCalledExactly(2)
|
accountManagementUrlResult.assertions().isCalledOnce()
|
||||||
.withSequence(
|
.with(value(null))
|
||||||
listOf(value(AccountManagementAction.Profile)),
|
|
||||||
listOf(value(AccountManagementAction.DevicesList)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,6 +109,7 @@ class LoggedInPresenterTest {
|
||||||
val verificationService = FakeSessionVerificationService()
|
val verificationService = FakeSessionVerificationService()
|
||||||
val encryptionService = FakeEncryptionService()
|
val encryptionService = FakeEncryptionService()
|
||||||
val buildMeta = aBuildMeta()
|
val buildMeta = aBuildMeta()
|
||||||
|
val networkMonitor = FakeNetworkMonitor()
|
||||||
LoggedInPresenter(
|
LoggedInPresenter(
|
||||||
matrixClient = FakeMatrixClient(
|
matrixClient = FakeMatrixClient(
|
||||||
roomListService = roomListService,
|
roomListService = roomListService,
|
||||||
|
|
@ -122,6 +123,7 @@ class LoggedInPresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
|
networkMonitor = networkMonitor,
|
||||||
).test {
|
).test {
|
||||||
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
||||||
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
|
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 {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
return awaitItem()
|
return awaitItem()
|
||||||
|
|
@ -334,6 +357,7 @@ class LoggedInPresenterTest {
|
||||||
accountManagementUrlResult = { Result.success(null) },
|
accountManagementUrlResult = { Result.success(null) },
|
||||||
),
|
),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
|
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||||
): LoggedInPresenter {
|
): LoggedInPresenter {
|
||||||
return LoggedInPresenter(
|
return LoggedInPresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
|
@ -343,6 +367,7 @@ class LoggedInPresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
buildMeta = buildMeta,
|
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
|
package io.element.android.features.announcement.api
|
||||||
|
|
||||||
enum class Announcement {
|
import androidx.compose.runtime.Immutable
|
||||||
Space,
|
|
||||||
NewNotificationSound,
|
@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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.announcement.api.Announcement
|
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.AnnouncementStatus
|
||||||
import io.element.android.features.announcement.impl.store.AnnouncementStore
|
import io.element.android.features.announcement.impl.store.AnnouncementStore
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
class AnnouncementPresenter(
|
class AnnouncementPresenter(
|
||||||
|
|
@ -25,13 +29,39 @@ class AnnouncementPresenter(
|
||||||
) : Presenter<AnnouncementState> {
|
) : Presenter<AnnouncementState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AnnouncementState {
|
override fun present(): AnnouncementState {
|
||||||
val showSpaceAnnouncement by remember {
|
val coroutineScope = rememberCoroutineScope()
|
||||||
announcementStore.announcementStatusFlow(Announcement.Space).map {
|
|
||||||
it == AnnouncementStatus.Show
|
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(
|
return AnnouncementState(
|
||||||
showSpaceAnnouncement = showSpaceAnnouncement,
|
announcement = fullscreenAnnouncementToShow,
|
||||||
|
eventSink = ::handle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,9 @@
|
||||||
|
|
||||||
package io.element.android.features.announcement.impl
|
package io.element.android.features.announcement.impl
|
||||||
|
|
||||||
data class AnnouncementState(
|
import io.element.android.features.announcement.api.Announcement
|
||||||
val showSpaceAnnouncement: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun anAnnouncementState(
|
data class AnnouncementState(
|
||||||
showSpaceAnnouncement: Boolean = false,
|
val announcement: Announcement.Fullscreen?,
|
||||||
) = AnnouncementState(
|
val eventSink: (AnnouncementEvent) -> Unit,
|
||||||
showSpaceAnnouncement = showSpaceAnnouncement,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.features.announcement.api.Announcement
|
import io.element.android.features.announcement.api.Announcement
|
||||||
import io.element.android.features.announcement.api.AnnouncementService
|
import io.element.android.features.announcement.api.AnnouncementService
|
||||||
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState
|
import io.element.android.features.announcement.impl.fullscreen.FullscreenAnnouncementView
|
||||||
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView
|
|
||||||
import io.element.android.features.announcement.impl.store.AnnouncementStatus
|
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.AnnouncementStore
|
||||||
import io.element.android.libraries.architecture.Presenter
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultAnnouncementService(
|
class DefaultAnnouncementService(
|
||||||
private val announcementStore: AnnouncementStore,
|
private val announcementStore: AnnouncementStore,
|
||||||
private val announcementPresenter: Presenter<AnnouncementState>,
|
private val announcementPresenter: AnnouncementPresenter,
|
||||||
private val spaceAnnouncementPresenter: Presenter<SpaceAnnouncementState>,
|
|
||||||
) : AnnouncementService {
|
) : AnnouncementService {
|
||||||
override suspend fun showAnnouncement(announcement: Announcement) {
|
override suspend fun showAnnouncement(announcement: Announcement) {
|
||||||
when (announcement) {
|
when (announcement) {
|
||||||
Announcement.Space -> showSpaceAnnouncement()
|
is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement)
|
||||||
Announcement.NewNotificationSound -> {
|
Announcement.NewNotificationSound -> {
|
||||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
||||||
}
|
}
|
||||||
|
|
@ -49,13 +42,10 @@ class DefaultAnnouncementService(
|
||||||
|
|
||||||
override fun announcementsToShowFlow(): Flow<List<Announcement>> {
|
override fun announcementsToShowFlow(): Flow<List<Announcement>> {
|
||||||
return combine(
|
return combine(
|
||||||
announcementStore.announcementStatusFlow(Announcement.Space),
|
flowOf(Unit),
|
||||||
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
|
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
|
||||||
) { spaceAnnouncementStatus, newNotificationSoundStatus ->
|
) { _, newNotificationSoundStatus ->
|
||||||
buildList {
|
buildList {
|
||||||
if (spaceAnnouncementStatus == AnnouncementStatus.Show) {
|
|
||||||
add(Announcement.Space)
|
|
||||||
}
|
|
||||||
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
|
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
|
||||||
add(Announcement.NewNotificationSound)
|
add(Announcement.NewNotificationSound)
|
||||||
}
|
}
|
||||||
|
|
@ -63,27 +53,19 @@ class DefaultAnnouncementService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun showSpaceAnnouncement() {
|
private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) {
|
||||||
val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first()
|
val currentValue = announcementStore.announcementStatusFlow(announcement).first()
|
||||||
if (currentValue == AnnouncementStatus.NeverShown) {
|
if (currentValue == AnnouncementStatus.NeverShown) {
|
||||||
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
|
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Render(modifier: Modifier) {
|
override fun Render(modifier: Modifier) {
|
||||||
val announcementState = announcementPresenter.present()
|
val announcementState = announcementPresenter.present()
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
FullscreenAnnouncementView(
|
||||||
AnimatedVisibility(
|
state = announcementState,
|
||||||
visible = announcementState.showSpaceAnnouncement,
|
modifier = modifier,
|
||||||
enter = fadeIn(),
|
)
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
val spaceAnnouncementState = spaceAnnouncementPresenter.present()
|
|
||||||
SpaceAnnouncementView(
|
|
||||||
state = spaceAnnouncementState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
|
||||||
val key = announcement.toKey()
|
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)
|
// For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08)
|
||||||
val defaultStatus = when (announcement) {
|
val defaultStatus = when (announcement) {
|
||||||
Announcement.Space -> AnnouncementStatus.NeverShown
|
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
|
||||||
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
|
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
|
||||||
}
|
}
|
||||||
return store.data.map { prefs ->
|
return store.data.map { prefs ->
|
||||||
|
|
@ -52,6 +53,6 @@ class DefaultAnnouncementStore(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Announcement.toKey() = when (this) {
|
private fun Announcement.toKey() = when (this) {
|
||||||
Announcement.Space -> spaceAnnouncementKey
|
Announcement.Fullscreen.Space -> spaceAnnouncementKey
|
||||||
Announcement.NewNotificationSound -> newNotificationSoundKey
|
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.AnnouncementStore
|
||||||
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
|
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -23,25 +24,47 @@ class AnnouncementPresenterTest {
|
||||||
val presenter = createAnnouncementPresenter()
|
val presenter = createAnnouncementPresenter()
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.showSpaceAnnouncement).isFalse()
|
assertThat(state.announcement).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 store = InMemoryAnnouncementStore()
|
||||||
val presenter = createAnnouncementPresenter(
|
val presenter = createAnnouncementPresenter(
|
||||||
announcementStore = store,
|
announcementStore = store,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.showSpaceAnnouncement).isFalse()
|
assertThat(state.announcement).isNull()
|
||||||
store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
|
store.setAnnouncementStatus(Announcement.Fullscreen.Space, AnnouncementStatus.Show)
|
||||||
val updatedState = awaitItem()
|
val updatedState = awaitItem()
|
||||||
assertThat(updatedState.showSpaceAnnouncement).isTrue()
|
assertThat(updatedState.announcement).isEqualTo(Announcement.Fullscreen.Space)
|
||||||
store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
|
store.setAnnouncementStatus(Announcement.Fullscreen.Space, AnnouncementStatus.Shown)
|
||||||
val finalState = awaitItem()
|
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 app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.announcement.api.Announcement
|
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.AnnouncementStatus
|
||||||
import io.element.android.features.announcement.impl.store.AnnouncementStore
|
import io.element.android.features.announcement.impl.store.AnnouncementStore
|
||||||
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
|
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
|
||||||
import io.element.android.libraries.architecture.Presenter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DefaultAnnouncementServiceTest {
|
class DefaultAnnouncementServiceTest {
|
||||||
@Test
|
@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 announcementStore = InMemoryAnnouncementStore()
|
||||||
val sut = createDefaultAnnouncementService(
|
val sut = createDefaultAnnouncementService(
|
||||||
announcementStore = announcementStore,
|
announcementStore = announcementStore,
|
||||||
)
|
)
|
||||||
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
|
assertThat(announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
|
||||||
sut.showAnnouncement(Announcement.Space)
|
sut.showAnnouncement(Announcement.Fullscreen.Space)
|
||||||
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Show)
|
assertThat(announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).first()).isEqualTo(AnnouncementStatus.Show)
|
||||||
// Simulate user close the announcement
|
// Simulate user close the announcement
|
||||||
sut.onAnnouncementDismissed(Announcement.Space)
|
sut.onAnnouncementDismissed(Announcement.Fullscreen.Space)
|
||||||
// Entering again the space tab should not change the value
|
// Entering again the space tab should not change the value
|
||||||
sut.showAnnouncement(Announcement.Space)
|
sut.showAnnouncement(Announcement.Fullscreen.Space)
|
||||||
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown)
|
assertThat(announcementStore.announcementStatusFlow(Announcement.Fullscreen.Space).first()).isEqualTo(AnnouncementStatus.Shown)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -62,11 +59,7 @@ class DefaultAnnouncementServiceTest {
|
||||||
)
|
)
|
||||||
sut.announcementsToShowFlow().test {
|
sut.announcementsToShowFlow().test {
|
||||||
assertThat(awaitItem()).isEmpty()
|
assertThat(awaitItem()).isEmpty()
|
||||||
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
|
|
||||||
assertThat(awaitItem()).containsExactly(Announcement.Space)
|
|
||||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
||||||
assertThat(awaitItem()).containsExactly(Announcement.Space, Announcement.NewNotificationSound)
|
|
||||||
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
|
|
||||||
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
|
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
|
||||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
|
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
|
||||||
assertThat(awaitItem()).isEmpty()
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
|
@ -75,11 +68,9 @@ class DefaultAnnouncementServiceTest {
|
||||||
|
|
||||||
private fun createDefaultAnnouncementService(
|
private fun createDefaultAnnouncementService(
|
||||||
announcementStore: AnnouncementStore = InMemoryAnnouncementStore(),
|
announcementStore: AnnouncementStore = InMemoryAnnouncementStore(),
|
||||||
announcementPresenter: Presenter<AnnouncementState> = Presenter { anAnnouncementState() },
|
announcementPresenter: AnnouncementPresenter = AnnouncementPresenter(announcementStore),
|
||||||
spaceAnnouncementPresenter: Presenter<SpaceAnnouncementState> = Presenter { aSpaceAnnouncementState() },
|
|
||||||
) = DefaultAnnouncementService(
|
) = DefaultAnnouncementService(
|
||||||
announcementStore = announcementStore,
|
announcementStore = announcementStore,
|
||||||
announcementPresenter = announcementPresenter,
|
announcementPresenter = announcementPresenter,
|
||||||
spaceAnnouncementPresenter = spaceAnnouncementPresenter,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,16 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* 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.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
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.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
|
@ -22,39 +26,41 @@ import org.junit.rules.TestRule
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SpaceAnnouncementViewTest {
|
class FullscreenAnnouncementViewTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on back sends a SpaceAnnouncementEvents`() {
|
fun `clicking on back sends a AnnouncementEvent`() {
|
||||||
val eventsRecorder = EventsRecorder<SpaceAnnouncementEvents>()
|
val eventsRecorder = EventsRecorder<AnnouncementEvent>()
|
||||||
rule.setSpaceAnnouncementView(
|
rule.setFullscreenAnnouncementView(
|
||||||
aSpaceAnnouncementState(
|
anAnnouncementState(
|
||||||
|
announcement = Announcement.Fullscreen.Space,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.pressBackKey()
|
rule.pressBackKey()
|
||||||
eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue)
|
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on Continue sends a SpaceAnnouncementEvents`() {
|
fun `clicking on Continue sends a AnnouncementEvent`() {
|
||||||
val eventsRecorder = EventsRecorder<SpaceAnnouncementEvents>()
|
val eventsRecorder = EventsRecorder<AnnouncementEvent>()
|
||||||
rule.setSpaceAnnouncementView(
|
rule.setFullscreenAnnouncementView(
|
||||||
aSpaceAnnouncementState(
|
anAnnouncementState(
|
||||||
|
announcement = Announcement.Fullscreen.Space,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_continue)
|
rule.clickOn(CommonStrings.action_continue)
|
||||||
eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue)
|
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceAnnouncementView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setFullscreenAnnouncementView(
|
||||||
state: SpaceAnnouncementState,
|
state: AnnouncementState,
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
SpaceAnnouncementView(
|
FullscreenAnnouncementView(
|
||||||
state = state,
|
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
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
class InMemoryAnnouncementStore(
|
class InMemoryAnnouncementStore(
|
||||||
initialSpaceAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||||
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||||
) : AnnouncementStore {
|
) : AnnouncementStore {
|
||||||
private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus)
|
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
|
||||||
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
|
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
|
||||||
|
|
||||||
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
|
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
|
||||||
|
|
@ -29,12 +29,12 @@ class InMemoryAnnouncementStore(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
spaceAnnouncement.value = AnnouncementStatus.NeverShown
|
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
|
||||||
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
|
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Announcement.toMutableStateFlow() = when (this) {
|
private fun Announcement.toMutableStateFlow() = when (this) {
|
||||||
Announcement.Space -> spaceAnnouncement
|
is Announcement.Fullscreen -> fullScreenAnnouncement
|
||||||
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
|
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
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.roomlist.RoomListState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
|
|
@ -47,7 +45,6 @@ class HomePresenter(
|
||||||
private val logoutPresenter: Presenter<DirectLogoutState>,
|
private val logoutPresenter: Presenter<DirectLogoutState>,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val announcementService: AnnouncementService,
|
|
||||||
) : Presenter<HomeState> {
|
) : Presenter<HomeState> {
|
||||||
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
||||||
|
|
||||||
|
|
@ -82,10 +79,7 @@ class HomePresenter(
|
||||||
|
|
||||||
fun handleEvent(event: HomeEvent) {
|
fun handleEvent(event: HomeEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is HomeEvent.SelectHomeNavigationBarItem -> coroutineState.launch {
|
is HomeEvent.SelectHomeNavigationBarItem -> {
|
||||||
if (event.item == HomeNavigationBarItem.Spaces) {
|
|
||||||
announcementService.showAnnouncement(Announcement.Space)
|
|
||||||
}
|
|
||||||
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
||||||
}
|
}
|
||||||
is HomeEvent.SwitchToAccount -> coroutineState.launch {
|
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.roomListRoomMessageDate
|
||||||
import io.element.android.libraries.designsystem.theme.roomListRoomName
|
import io.element.android.libraries.designsystem.theme.roomListRoomName
|
||||||
import io.element.android.libraries.designsystem.theme.unreadIndicator
|
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.api.room.RoomNotificationMode
|
||||||
import io.element.android.libraries.matrix.ui.components.InviteSenderView
|
import io.element.android.libraries.matrix.ui.components.InviteSenderView
|
||||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||||
|
|
@ -349,6 +350,7 @@ private fun MessagePreviewAndIndicatorRow(
|
||||||
if (room.hasRoomCall) {
|
if (room.hasRoomCall) {
|
||||||
OnGoingCallIcon(
|
OnGoingCallIcon(
|
||||||
color = tint,
|
color = tint,
|
||||||
|
isAudio = room.activeCallIntent == CallIntent.AUDIO
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||||
|
|
@ -398,10 +400,11 @@ private fun InviteNameAndIndicatorRow(
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnGoingCallIcon(
|
private fun OnGoingCallIcon(
|
||||||
color: Color,
|
color: Color,
|
||||||
|
isAudio: Boolean
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
imageVector = CompoundIcons.VideoCallSolid(),
|
imageVector = if (isAudio) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
|
||||||
contentDescription = stringResource(CommonStrings.a11y_notifications_ongoing_call),
|
contentDescription = stringResource(CommonStrings.a11y_notifications_ongoing_call),
|
||||||
tint = color,
|
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.dateformatter.api.DateFormatterMode
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
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.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
import io.element.android.libraries.matrix.api.room.isDm
|
||||||
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||||
|
|
@ -50,6 +51,11 @@ class RoomListRoomSummaryFactory(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
|
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
|
||||||
hasRoomCall = roomInfo.hasRoomCall,
|
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,
|
isDirect = roomInfo.isDirect,
|
||||||
isFavorite = roomInfo.isFavorite,
|
isFavorite = roomInfo.isFavorite,
|
||||||
inviteSender = roomInfo.inviter?.toInviteSender(),
|
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.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
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.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.api.room.RoomNotificationMode
|
||||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -33,6 +34,7 @@ data class RoomListRoomSummary(
|
||||||
val avatarData: AvatarData,
|
val avatarData: AvatarData,
|
||||||
val userDefinedNotificationMode: RoomNotificationMode?,
|
val userDefinedNotificationMode: RoomNotificationMode?,
|
||||||
val hasRoomCall: Boolean,
|
val hasRoomCall: Boolean,
|
||||||
|
val activeCallIntent: CallIntent?,
|
||||||
val isDirect: Boolean,
|
val isDirect: Boolean,
|
||||||
val isDm: Boolean,
|
val isDm: Boolean,
|
||||||
val isFavorite: 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.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.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.api.room.RoomNotificationMode
|
||||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -132,6 +133,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
|
||||||
listOf(
|
listOf(
|
||||||
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
|
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
|
||||||
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
|
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
aRoomListRoomSummary(
|
||||||
|
name = "Active voice call",
|
||||||
|
latestEvent = LatestEvent.Synced("No activity, call"),
|
||||||
|
hasRoomCall = true,
|
||||||
|
activeCallIntent = CallIntent.AUDIO
|
||||||
|
),
|
||||||
)
|
)
|
||||||
).flatten()
|
).flatten()
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +167,7 @@ internal fun aRoomListRoomSummary(
|
||||||
timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
|
timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
|
||||||
notificationMode: RoomNotificationMode? = null,
|
notificationMode: RoomNotificationMode? = null,
|
||||||
hasRoomCall: Boolean = false,
|
hasRoomCall: Boolean = false,
|
||||||
|
activeCallIntent: CallIntent? = null,
|
||||||
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
|
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
|
||||||
isDirect: Boolean = false,
|
isDirect: Boolean = false,
|
||||||
isDm: Boolean = false,
|
isDm: Boolean = false,
|
||||||
|
|
@ -181,6 +191,7 @@ internal fun aRoomListRoomSummary(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
userDefinedNotificationMode = notificationMode,
|
userDefinedNotificationMode = notificationMode,
|
||||||
hasRoomCall = hasRoomCall,
|
hasRoomCall = hasRoomCall,
|
||||||
|
activeCallIntent = activeCallIntent,
|
||||||
isDirect = isDirect,
|
isDirect = isDirect,
|
||||||
isDm = isDm,
|
isDm = isDm,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@
|
||||||
package io.element.android.features.home.impl
|
package io.element.android.features.home.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.roomlist.aRoomListState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.home.impl.spaces.aHomeSpacesState
|
import io.element.android.features.home.impl.spaces.aHomeSpacesState
|
||||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.indicator.api.IndicatorService
|
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.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|
||||||
import io.element.android.tests.testutils.lambda.value
|
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -137,14 +132,10 @@ class HomePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - NavigationBar change`() = runTest {
|
fun `present - NavigationBar change`() = runTest {
|
||||||
val showAnnouncementResult = lambdaRecorder<Announcement, Unit> { }
|
|
||||||
val presenter = createHomePresenter(
|
val presenter = createHomePresenter(
|
||||||
sessionStore = InMemorySessionStore(
|
sessionStore = InMemorySessionStore(
|
||||||
updateUserProfileResult = { _, _, _ -> },
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
),
|
),
|
||||||
announcementService = FakeAnnouncementService(
|
|
||||||
showAnnouncementResult = showAnnouncementResult,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
@ -152,8 +143,6 @@ class HomePresenterTest {
|
||||||
initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
|
initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
|
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
|
||||||
showAnnouncementResult.assertions().isCalledOnce()
|
|
||||||
.with(value(Announcement.Space))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +155,6 @@ internal fun createHomePresenter(
|
||||||
indicatorService: IndicatorService = FakeIndicatorService(),
|
indicatorService: IndicatorService = FakeIndicatorService(),
|
||||||
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
||||||
sessionStore: SessionStore = InMemorySessionStore(),
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
announcementService: AnnouncementService = FakeAnnouncementService(),
|
|
||||||
) = HomePresenter(
|
) = HomePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
syncService = syncService,
|
syncService = syncService,
|
||||||
|
|
@ -177,5 +165,4 @@ internal fun createHomePresenter(
|
||||||
logoutPresenter = { aDirectLogoutState() },
|
logoutPresenter = { aDirectLogoutState() },
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
announcementService = announcementService,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ internal fun createRoomListRoomSummary(
|
||||||
displayType = displayType,
|
displayType = displayType,
|
||||||
userDefinedNotificationMode = userDefinedNotificationMode,
|
userDefinedNotificationMode = userDefinedNotificationMode,
|
||||||
hasRoomCall = false,
|
hasRoomCall = false,
|
||||||
|
activeCallIntent = null,
|
||||||
isDirect = false,
|
isDirect = false,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
canonicalAlias = null,
|
canonicalAlias = null,
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,12 @@ dependencies {
|
||||||
implementation(projects.libraries.usersearch.api)
|
implementation(projects.libraries.usersearch.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(projects.services.apperror.api)
|
implementation(projects.services.apperror.api)
|
||||||
|
implementation(projects.libraries.featureflag.api)
|
||||||
api(projects.features.invitepeople.api)
|
api(projects.features.invitepeople.api)
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.usersearch.test)
|
testImplementation(projects.libraries.usersearch.test)
|
||||||
testImplementation(projects.services.apperror.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 {
|
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
|
||||||
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
|
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
|
||||||
data class OnSearchActiveChanged(val active: Boolean) : 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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.filterNot
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -69,6 +74,7 @@ class DefaultInvitePeoplePresenter(
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val appErrorStateService: AppErrorStateService,
|
private val appErrorStateService: AppErrorStateService,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
) : InvitePeoplePresenter {
|
) : InvitePeoplePresenter {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -87,6 +93,8 @@ class DefaultInvitePeoplePresenter(
|
||||||
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||||
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
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) {
|
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
|
||||||
if (roomMembers.value.isSuccess()) {
|
if (roomMembers.value.isSuccess()) {
|
||||||
val activeMemberIds = roomMembers.value.dataOrNull().orEmpty()
|
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()) {
|
LaunchedEffect(room.isSuccess()) {
|
||||||
room.dataOrNull()?.let {
|
room.dataOrNull()?.let {
|
||||||
fetchMembers(it, roomMembers)
|
fetchMembers(it, roomMembers)
|
||||||
|
|
@ -144,21 +186,41 @@ class DefaultInvitePeoplePresenter(
|
||||||
|
|
||||||
fun handleEvent(event: InvitePeopleEvents) {
|
fun handleEvent(event: InvitePeopleEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
|
// Dedicated `when` for exhaustivity.
|
||||||
searchActive = event.active
|
is DefaultInvitePeopleEvents -> when (event) {
|
||||||
if (!event.active) {
|
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
|
||||||
queryState.clearText()
|
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 -> {
|
is InvitePeopleEvents.SendInvites -> {
|
||||||
room.dataOrNull()?.let {
|
if (enableKeyShareOnInvite && unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
|
||||||
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
|
sendInvitesAction.value = ConfirmingUnknownUserInvitation(
|
||||||
|
unknownUsers
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
room.dataOrNull()?.let {
|
||||||
|
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.CloseSearch -> {
|
is InvitePeopleEvents.CloseSearch -> {
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,16 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
|
||||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||||
sendInvitesAction = AsyncAction.Loading,
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
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.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.AsyncFailure
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.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.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.SearchBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
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.CheckableUserRow
|
||||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
|
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.components.SelectedUsersRowList
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.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.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
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
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
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.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.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.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.api.AppErrorStateService
|
||||||
import io.element.android.services.apperror.test.FakeAppErrorStateService
|
import io.element.android.services.apperror.test.FakeAppErrorStateService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
|
@ -56,6 +62,7 @@ import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
internal class DefaultInvitePeoplePresenterTest {
|
internal class DefaultInvitePeoplePresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
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(
|
private suspend fun FakeUserRepository.emitStateWithUsers(
|
||||||
users: List<MatrixUser>,
|
users: List<MatrixUser>,
|
||||||
isSearching: Boolean = false
|
isSearching: Boolean = false
|
||||||
|
|
@ -646,6 +878,7 @@ fun TestScope.createDefaultInvitePeoplePresenter(
|
||||||
userRepository: UserRepository = FakeUserRepository(),
|
userRepository: UserRepository = FakeUserRepository(),
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
appErrorStateService: AppErrorStateService = FakeAppErrorStateService(),
|
appErrorStateService: AppErrorStateService = FakeAppErrorStateService(),
|
||||||
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
): DefaultInvitePeoplePresenter {
|
): DefaultInvitePeoplePresenter {
|
||||||
return DefaultInvitePeoplePresenter(
|
return DefaultInvitePeoplePresenter(
|
||||||
|
|
@ -655,6 +888,7 @@ fun TestScope.createDefaultInvitePeoplePresenter(
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
sessionCoroutineScope = backgroundScope,
|
sessionCoroutineScope = backgroundScope,
|
||||||
appErrorStateService = appErrorStateService,
|
appErrorStateService = appErrorStateService,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ setupDependencyInjection()
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.appconfig)
|
implementation(projects.appconfig)
|
||||||
implementation(projects.features.enterprise.api)
|
implementation(projects.features.enterprise.api)
|
||||||
|
implementation(projects.features.preferences.api)
|
||||||
implementation(projects.features.rageshake.api)
|
implementation(projects.features.rageshake.api)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
|
@ -79,6 +80,7 @@ dependencies {
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.features.login.test)
|
testImplementation(projects.features.login.test)
|
||||||
testImplementation(projects.features.enterprise.test)
|
testImplementation(projects.features.enterprise.test)
|
||||||
|
testImplementation(projects.features.preferences.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.oidc.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.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
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 com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Assisted
|
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.compound.theme.ElementTheme
|
||||||
import io.element.android.features.login.api.LoginEntryPoint
|
import io.element.android.features.login.api.LoginEntryPoint
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.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.qrcode.QrCodeLoginFlowNode
|
||||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
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.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.confirmaccountprovider.ConfirmAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
|
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.loginpassword.LoginPasswordNode
|
||||||
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
|
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.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.androidutils.browser.openUrlInChromeCustomTab
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
|
|
@ -63,9 +67,11 @@ class LoginFlowNode(
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val elementClassicConnection: ElementClassicConnection,
|
||||||
|
private val preferencesEntryPoint: PreferencesEntryPoint,
|
||||||
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.OnBoarding,
|
initialElement = NavTarget.CheckClassicFlow,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
|
@ -103,11 +109,19 @@ class LoginFlowNode(
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object OnBoarding : NavTarget
|
data object CheckClassicFlow : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class OnBoarding(
|
||||||
|
val showBackButton: Boolean,
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object QrCode : NavTarget
|
data object QrCode : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object AppDeveloperSettings : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ConfirmAccountProvider(
|
data class ConfirmAccountProvider(
|
||||||
val isAccountCreation: Boolean,
|
val isAccountCreation: Boolean,
|
||||||
|
|
@ -123,7 +137,9 @@ class LoginFlowNode(
|
||||||
data object SearchAccountProvider : NavTarget
|
data object SearchAccountProvider : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object LoginPassword : NavTarget
|
data class LoginPassword(
|
||||||
|
val initialLogin: String = "",
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class CreateAccount(val url: String) : NavTarget
|
data class CreateAccount(val url: String) : NavTarget
|
||||||
|
|
@ -131,7 +147,31 @@ class LoginFlowNode(
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
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 {
|
val callback = object : OnBoardingNode.Callback {
|
||||||
override fun navigateToSignUpFlow() {
|
override fun navigateToSignUpFlow() {
|
||||||
backstack.push(
|
backstack.push(
|
||||||
|
|
@ -165,21 +205,42 @@ class LoginFlowNode(
|
||||||
backstack.push(NavTarget.CreateAccount(url))
|
backstack.push(NavTarget.CreateAccount(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
backstack.push(NavTarget.AppDeveloperSettings)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
callback.onDone()
|
if (navTarget.showBackButton) {
|
||||||
|
backstack.pop()
|
||||||
|
} else {
|
||||||
|
callback.onDone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val params = inputs<Params>()
|
val params = inputs<Params>()
|
||||||
val inputs = OnBoardingNode.Params(
|
val inputs = OnBoardingNode.Params(
|
||||||
accountProvider = params.accountProvider,
|
accountProvider = params.accountProvider,
|
||||||
loginHint = params.loginHint,
|
loginHint = params.loginHint,
|
||||||
|
showBackButton = navTarget.showBackButton,
|
||||||
)
|
)
|
||||||
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
|
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 -> {
|
NavTarget.ChooseAccountProvider -> {
|
||||||
val callback = object : ChooseAccountProviderNode.Callback {
|
val callback = object : ChooseAccountProviderNode.Callback {
|
||||||
override fun navigateToOidc(oidcDetails: OidcDetails) {
|
override fun navigateToOidc(oidcDetails: OidcDetails) {
|
||||||
|
|
@ -191,7 +252,7 @@ class LoginFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
|
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
|
||||||
|
|
@ -218,7 +279,7 @@ class LoginFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToChangeAccountProvider() {
|
override fun navigateToChangeAccountProvider() {
|
||||||
|
|
@ -257,8 +318,11 @@ class LoginFlowNode(
|
||||||
|
|
||||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.LoginPassword -> {
|
is NavTarget.LoginPassword -> {
|
||||||
createNode<LoginPasswordNode>(buildContext)
|
val inputs = LoginPasswordNode.Inputs(
|
||||||
|
initialLogin = navTarget.initialLogin,
|
||||||
|
)
|
||||||
|
createNode<LoginPasswordNode>(buildContext, plugins = listOf(inputs))
|
||||||
}
|
}
|
||||||
is NavTarget.CreateAccount -> {
|
is NavTarget.CreateAccount -> {
|
||||||
val inputs = CreateAccountNode.Inputs(
|
val inputs = CreateAccountNode.Inputs(
|
||||||
|
|
@ -280,6 +344,14 @@ class LoginFlowNode(
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
activity = requireNotNull(LocalActivity.current)
|
activity = requireNotNull(LocalActivity.current)
|
||||||
darkTheme = !ElementTheme.isLightTheme
|
darkTheme = !ElementTheme.isLightTheme
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
elementClassicConnection.start()
|
||||||
|
onDispose {
|
||||||
|
elementClassicConnection.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
activity = null
|
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 dev.zacsweers.metro.ContributesTo
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
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.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
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
|
|
@ -23,7 +21,4 @@ import io.element.android.libraries.architecture.Presenter
|
||||||
interface LoginModule {
|
interface LoginModule {
|
||||||
@Binds
|
@Binds
|
||||||
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
|
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
|
||||||
|
|
||||||
@Binds
|
|
||||||
fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter<LoginWithClassicState>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,19 @@ class LoginHelper(
|
||||||
suspend fun submit(
|
suspend fun submit(
|
||||||
isAccountCreation: Boolean,
|
isAccountCreation: Boolean,
|
||||||
homeserverUrl: String,
|
homeserverUrl: String,
|
||||||
|
resolvedHomeserverUrl: String?,
|
||||||
loginHint: String?,
|
loginHint: String?,
|
||||||
) {
|
) {
|
||||||
suspend {
|
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) {
|
if (matrixHomeServerDetails.supportsOidcLogin) {
|
||||||
// Retrieve the details right now
|
// Retrieve the details right now
|
||||||
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
|
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter(
|
||||||
loginHelper.submit(
|
loginHelper.submit(
|
||||||
isAccountCreation = false,
|
isAccountCreation = false,
|
||||||
homeserverUrl = it.url,
|
homeserverUrl = it.url,
|
||||||
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = 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(
|
loginHelper.submit(
|
||||||
isAccountCreation = params.isAccountCreation,
|
isAccountCreation = params.isAccountCreation,
|
||||||
homeserverUrl = accountProvider.url,
|
homeserverUrl = accountProvider.url,
|
||||||
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,23 @@ import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class LoginPasswordNode(
|
class LoginPasswordNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: LoginPasswordPresenter,
|
presenterFactory: LoginPasswordPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
data class Inputs(
|
||||||
|
val initialLogin: String,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
private val presenter = presenterFactory.create(inputs.initialLogin)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Inject
|
@AssistedInject
|
||||||
class LoginPasswordPresenter(
|
class LoginPasswordPresenter(
|
||||||
|
@Assisted
|
||||||
|
private val initialLogin: String,
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
) : Presenter<LoginPasswordState> {
|
) : Presenter<LoginPasswordState> {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(initialLogin: String): LoginPasswordPresenter
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoginPasswordState {
|
override fun present(): LoginPasswordState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -38,7 +47,12 @@ class LoginPasswordPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
val formState = rememberSaveable {
|
val formState = rememberSaveable {
|
||||||
mutableStateOf(LoginFormState.Default)
|
mutableStateOf(
|
||||||
|
LoginFormState(
|
||||||
|
login = initialLogin,
|
||||||
|
password = "",
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,14 @@ class OnBoardingNode(
|
||||||
fun navigateToLoginPassword()
|
fun navigateToLoginPassword()
|
||||||
fun navigateToOidc(oidcDetails: OidcDetails)
|
fun navigateToOidc(oidcDetails: OidcDetails)
|
||||||
fun navigateToCreateAccount(url: String)
|
fun navigateToCreateAccount(url: String)
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
fun onDone()
|
fun onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
val accountProvider: String?,
|
val accountProvider: String?,
|
||||||
val loginHint: String?,
|
val loginHint: String?,
|
||||||
|
val showBackButton: Boolean,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
|
@ -61,6 +63,7 @@ class OnBoardingNode(
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
OnBoardingView(
|
OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -73,6 +76,7 @@ class OnBoardingNode(
|
||||||
onLearnMoreClick = { openLearnMorePage(context) },
|
onLearnMoreClick = { openLearnMorePage(context) },
|
||||||
onCreateAccountContinue = callback::navigateToCreateAccount,
|
onCreateAccountContinue = callback::navigateToCreateAccount,
|
||||||
onBackClick = callback::onDone,
|
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.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
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.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.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
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.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
|
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -45,7 +45,6 @@ class OnBoardingPresenter(
|
||||||
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
private val loginWithClassicPresenter: Presenter<LoginWithClassicState>,
|
|
||||||
) : Presenter<OnBoardingState> {
|
) : Presenter<OnBoardingState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -101,8 +100,6 @@ class OnBoardingPresenter(
|
||||||
|
|
||||||
val loginMode by loginHelper.collectLoginMode()
|
val loginMode by loginHelper.collectLoginMode()
|
||||||
|
|
||||||
val loginWithClassicState = loginWithClassicPresenter.present()
|
|
||||||
|
|
||||||
fun handleEvent(event: OnBoardingEvents) {
|
fun handleEvent(event: OnBoardingEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
|
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
|
||||||
|
|
@ -111,6 +108,7 @@ class OnBoardingPresenter(
|
||||||
loginHelper.submit(
|
loginHelper.submit(
|
||||||
isAccountCreation = false,
|
isAccountCreation = false,
|
||||||
homeserverUrl = event.defaultAccountProvider,
|
homeserverUrl = event.defaultAccountProvider,
|
||||||
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +125,8 @@ class OnBoardingPresenter(
|
||||||
|
|
||||||
return OnBoardingState(
|
return OnBoardingState(
|
||||||
isAddingAccount = isAddingAccount,
|
isAddingAccount = isAddingAccount,
|
||||||
|
showBackButton = params.showBackButton,
|
||||||
|
showDeveloperSettings = buildMeta.buildType != BuildType.RELEASE,
|
||||||
productionApplicationName = buildMeta.productionApplicationName,
|
productionApplicationName = buildMeta.productionApplicationName,
|
||||||
defaultAccountProvider = defaultAccountProvider,
|
defaultAccountProvider = defaultAccountProvider,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
|
@ -136,7 +136,6 @@ class OnBoardingPresenter(
|
||||||
loginMode = loginMode,
|
loginMode = loginMode,
|
||||||
version = buildMeta.versionName,
|
version = buildMeta.versionName,
|
||||||
onBoardingLogoResId = onBoardingLogoResId,
|
onBoardingLogoResId = onBoardingLogoResId,
|
||||||
loginWithClassicState = loginWithClassicState,
|
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ package io.element.android.features.login.impl.screens.onboarding
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import io.element.android.features.login.impl.login.LoginMode
|
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
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
data class OnBoardingState(
|
data class OnBoardingState(
|
||||||
val isAddingAccount: Boolean,
|
val isAddingAccount: Boolean,
|
||||||
|
val showBackButton: Boolean,
|
||||||
|
val showDeveloperSettings: Boolean,
|
||||||
val productionApplicationName: String,
|
val productionApplicationName: String,
|
||||||
val defaultAccountProvider: String?,
|
val defaultAccountProvider: String?,
|
||||||
val mustChooseAccountProvider: Boolean,
|
val mustChooseAccountProvider: Boolean,
|
||||||
|
|
@ -25,7 +26,6 @@ data class OnBoardingState(
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
val onBoardingLogoResId: Int?,
|
val onBoardingLogoResId: Int?,
|
||||||
val loginMode: AsyncData<LoginMode>,
|
val loginMode: AsyncData<LoginMode>,
|
||||||
val loginWithClassicState: LoginWithClassicState,
|
|
||||||
val eventSink: (OnBoardingEvents) -> Unit,
|
val eventSink: (OnBoardingEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val submitEnabled: Boolean
|
val submitEnabled: Boolean
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ package io.element.android.features.login.impl.screens.onboarding
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.login.impl.login.LoginMode
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.R
|
import io.element.android.libraries.designsystem.R
|
||||||
|
|
||||||
|
|
@ -31,11 +29,17 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||||
canLoginWithQrCode = true,
|
canLoginWithQrCode = true,
|
||||||
canCreateAccount = true,
|
canCreateAccount = true,
|
||||||
),
|
),
|
||||||
|
anOnBoardingState(
|
||||||
|
showBackButton = true,
|
||||||
|
showDeveloperSettings = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anOnBoardingState(
|
fun anOnBoardingState(
|
||||||
isAddingAccount: Boolean = false,
|
isAddingAccount: Boolean = false,
|
||||||
|
showBackButton: Boolean = false,
|
||||||
|
showDeveloperSettings: Boolean = false,
|
||||||
productionApplicationName: String = "Element",
|
productionApplicationName: String = "Element",
|
||||||
defaultAccountProvider: String? = null,
|
defaultAccountProvider: String? = null,
|
||||||
mustChooseAccountProvider: Boolean = false,
|
mustChooseAccountProvider: Boolean = false,
|
||||||
|
|
@ -46,10 +50,11 @@ fun anOnBoardingState(
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
customLogoResId: Int? = null,
|
customLogoResId: Int? = null,
|
||||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||||
loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
|
|
||||||
eventSink: (OnBoardingEvents) -> Unit = {},
|
eventSink: (OnBoardingEvents) -> Unit = {},
|
||||||
) = OnBoardingState(
|
) = OnBoardingState(
|
||||||
isAddingAccount = isAddingAccount,
|
isAddingAccount = isAddingAccount,
|
||||||
|
showBackButton = showBackButton,
|
||||||
|
showDeveloperSettings = showDeveloperSettings,
|
||||||
productionApplicationName = productionApplicationName,
|
productionApplicationName = productionApplicationName,
|
||||||
defaultAccountProvider = defaultAccountProvider,
|
defaultAccountProvider = defaultAccountProvider,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
|
@ -59,6 +64,5 @@ fun anOnBoardingState(
|
||||||
version = version,
|
version = version,
|
||||||
loginMode = loginMode,
|
loginMode = loginMode,
|
||||||
onBoardingLogoResId = customLogoResId,
|
onBoardingLogoResId = customLogoResId,
|
||||||
loginWithClassicState = loginWithClassicState,
|
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.login.LoginModeView
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
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.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.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.IconSource
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
|
@ -69,6 +64,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun OnBoardingView(
|
fun OnBoardingView(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
|
onDeveloperSettingsClick: () -> Unit,
|
||||||
onSignInWithQrCode: () -> Unit,
|
onSignInWithQrCode: () -> Unit,
|
||||||
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
|
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
|
||||||
onCreateAccount: () -> Unit,
|
onCreateAccount: () -> Unit,
|
||||||
|
|
@ -114,45 +110,10 @@ fun OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
loginView = loginView,
|
loginView = loginView,
|
||||||
buttons = buttons,
|
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
|
@Composable
|
||||||
|
|
@ -160,18 +121,49 @@ private fun AddFirstAccountScaffold(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
loginView: @Composable () -> Unit,
|
loginView: @Composable () -> Unit,
|
||||||
buttons: @Composable () -> Unit,
|
buttons: @Composable () -> Unit,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onDeveloperSettingsClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnBoardingPage(
|
OnBoardingPage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
renderBackground = state.onBoardingLogoResId == null,
|
renderBackground = state.onBoardingLogoResId == null,
|
||||||
content = {
|
content = {
|
||||||
if (state.onBoardingLogoResId != null) {
|
Box(
|
||||||
OnBoardingLogo(
|
modifier = Modifier.fillMaxSize(),
|
||||||
onBoardingLogoResId = state.onBoardingLogoResId,
|
) {
|
||||||
)
|
if (state.onBoardingLogoResId != null) {
|
||||||
} else {
|
OnBoardingLogo(
|
||||||
OnBoardingContent(state = state)
|
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()
|
loginView()
|
||||||
},
|
},
|
||||||
|
|
@ -283,18 +275,6 @@ private fun OnBoardingButtons(
|
||||||
} else {
|
} else {
|
||||||
CommonStrings.action_continue
|
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) {
|
if (state.canLoginWithQrCode) {
|
||||||
Button(
|
Button(
|
||||||
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
|
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
|
||||||
|
|
@ -369,6 +349,7 @@ internal fun OnBoardingViewPreview(
|
||||||
OnBoardingView(
|
OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
onDeveloperSettingsClick = {},
|
||||||
onSignInWithQrCode = {},
|
onSignInWithQrCode = {},
|
||||||
onSignIn = {},
|
onSignIn = {},
|
||||||
onCreateAccount = {},
|
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_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
|
||||||
<string name="screen_login_title">"Welcome back!"</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_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_app_version">"Version %1$s"</string>
|
||||||
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</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_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_in_with_qr_code">"Sign in with QR code"</string>
|
||||||
<string name="screen_onboarding_sign_up">"Create account"</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_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_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
|
||||||
<string name="screen_onboarding_welcome_title">"Be in your element"</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.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.features.login.api.LoginEntryPoint
|
import io.element.android.features.login.api.LoginEntryPoint
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.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.libraries.oidc.test.customtab.FakeOidcActionFlow
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.node.TestParentNode
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
|
@ -39,6 +41,8 @@ class DefaultLoginEntryPointTest {
|
||||||
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
oidcActionFlow = FakeOidcActionFlow(),
|
oidcActionFlow = FakeOidcActionFlow(),
|
||||||
appCoroutineScope = backgroundScope,
|
appCoroutineScope = backgroundScope,
|
||||||
|
elementClassicConnection = FakeElementClassicConnection(),
|
||||||
|
preferencesEntryPoint = FakePreferencesEntryPoint(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : LoginEntryPoint.Callback {
|
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.
|
* 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 io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -15,12 +15,12 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
class FakeElementClassicConnection(
|
class FakeElementClassicConnection(
|
||||||
private val startResult: () -> Unit = { lambdaError() },
|
private val startResult: () -> Unit = { lambdaError() },
|
||||||
private val stopResult: () -> Unit = { lambdaError() },
|
private val stopResult: () -> Unit = { lambdaError() },
|
||||||
private val requestDataResult: () -> Unit = { lambdaError() },
|
private val requestSessionResult: () -> Unit = { lambdaError() },
|
||||||
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
|
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
|
||||||
) : ElementClassicConnection {
|
) : ElementClassicConnection {
|
||||||
override fun start() = startResult()
|
override fun start() = startResult()
|
||||||
override fun stop() = stopResult()
|
override fun stop() = stopResult()
|
||||||
override fun requestData() = requestDataResult()
|
override fun requestSession() = requestSessionResult()
|
||||||
private val mutableStateFlow = MutableStateFlow(initialState)
|
private val mutableStateFlow = MutableStateFlow(initialState)
|
||||||
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
|
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
|
||||||
suspend fun emitState(state: ElementClassicConnectionState) {
|
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_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
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
|
||||||
|
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.FakeMatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
|
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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
|
@Test
|
||||||
fun `present - enter login and password`() = runTest {
|
fun `present - enter login and password`() = runTest {
|
||||||
val authenticationService = FakeMatrixAuthenticationService(
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
|
@ -140,9 +155,11 @@ class LoginPasswordPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createLoginPasswordPresenter(
|
private fun createLoginPasswordPresenter(
|
||||||
|
initialLogin: String = "",
|
||||||
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
): LoginPasswordPresenter = LoginPasswordPresenter(
|
): LoginPasswordPresenter = LoginPasswordPresenter(
|
||||||
|
initialLogin = initialLogin,
|
||||||
authenticationService = authenticationService,
|
authenticationService = authenticationService,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
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.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
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.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.FakeWebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||||
|
|
@ -83,16 +82,31 @@ class OnBoardingPresenterTest {
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showBackButton).isFalse()
|
||||||
assertThat(initialState.defaultAccountProvider).isNull()
|
assertThat(initialState.defaultAccountProvider).isNull()
|
||||||
assertThat(initialState.canLoginWithQrCode).isFalse()
|
assertThat(initialState.canLoginWithQrCode).isFalse()
|
||||||
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
||||||
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
||||||
assertThat(initialState.canReportBug).isFalse()
|
assertThat(initialState.canReportBug).isFalse()
|
||||||
assertThat(initialState.isAddingAccount).isFalse()
|
assertThat(initialState.isAddingAccount).isFalse()
|
||||||
assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse()
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.canLoginWithQrCode).isTrue()
|
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(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
|
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
|
||||||
|
|
@ -184,6 +199,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
||||||
|
|
@ -206,6 +222,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
|
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
|
||||||
|
|
@ -233,6 +250,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = A_HOMESERVER_URL,
|
accountProvider = A_HOMESERVER_URL,
|
||||||
loginHint = A_LOGIN_HINT,
|
loginHint = A_LOGIN_HINT,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
isAllowedToConnectToHomeserverResult = { true },
|
isAllowedToConnectToHomeserverResult = { true },
|
||||||
|
|
@ -265,7 +283,11 @@ class OnBoardingPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
|
params: OnBoardingNode.Params = OnBoardingNode.Params(
|
||||||
|
accountProvider = null,
|
||||||
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
|
),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
||||||
|
|
@ -287,7 +309,6 @@ private fun createPresenter(
|
||||||
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
loginWithClassicPresenter = { aLoginWithClassicState() },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createLoginHelper(
|
fun createLoginHelper(
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ package io.element.android.features.login.impl.screens.onboarding
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
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.R
|
||||||
import io.element.android.features.login.impl.login.LoginMode
|
import io.element.android.features.login.impl.login.LoginMode
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
@ -31,8 +33,9 @@ import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestParameterInjector
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(RobolectricTestParameterInjector::class)
|
||||||
class OnboardingViewTest {
|
class OnboardingViewTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
@ -44,11 +47,15 @@ class OnboardingViewTest {
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(
|
state = anOnBoardingState(
|
||||||
canCreateAccount = true,
|
canCreateAccount = true,
|
||||||
|
showDeveloperSettings = false,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
),
|
),
|
||||||
onCreateAccount = callback,
|
onCreateAccount = callback,
|
||||||
)
|
)
|
||||||
rule.clickOn(R.string.screen_onboarding_sign_up)
|
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
|
@Test
|
||||||
fun `when can login with QR code - clicking on sign in manually calls the expected callback - can search account provider`() {
|
fun `when can login with QR code - clicking on sign in manually calls the expected callback`(
|
||||||
`when can login with QR code - clicking on sign in manually calls the expected callback`(
|
@TestParameter mustChooseAccountProvider: Boolean = namedTestValues(
|
||||||
mustChooseAccountProvider = false,
|
"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)
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
||||||
|
|
@ -114,21 +111,11 @@ class OnboardingViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - can search account provider`() {
|
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
|
||||||
`when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
|
@TestParameter mustChooseAccountProvider: Boolean = namedTestValues(
|
||||||
mustChooseAccountProvider = false,
|
"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)
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
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
|
@Test
|
||||||
fun `cannot report a problem when the feature is disabled`() {
|
fun `cannot report a problem when the feature is disabled`() {
|
||||||
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
|
|
@ -253,6 +256,7 @@ class OnboardingViewTest {
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
||||||
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onCreateAccount: () -> Unit = EnsureNeverCalled(),
|
onCreateAccount: () -> Unit = EnsureNeverCalled(),
|
||||||
|
|
@ -266,6 +270,7 @@ class OnboardingViewTest {
|
||||||
OnBoardingView(
|
OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
|
onDeveloperSettingsClick = onDeveloperSettingsClick,
|
||||||
onSignInWithQrCode = onSignInWithQrCode,
|
onSignInWithQrCode = onSignInWithQrCode,
|
||||||
onSignIn = onSignIn,
|
onSignIn = onSignIn,
|
||||||
onCreateAccount = onCreateAccount,
|
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.pinned.list.PinnedMessagesListNode
|
||||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
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.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.TimelineController
|
||||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
@ -179,6 +180,9 @@ class MessagesFlowNode(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object ThreadsList : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: MessagesEntryPoint.Callback = callback()
|
private val callback: MessagesEntryPoint.Callback = callback()
|
||||||
|
|
@ -294,6 +298,10 @@ class MessagesFlowNode(
|
||||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToThreadsList() {
|
||||||
|
backstack.push(NavTarget.ThreadsList)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToDeveloperSettings() {
|
override fun navigateToDeveloperSettings() {
|
||||||
callback.navigateToDeveloperSettings()
|
callback.navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
@ -517,6 +525,14 @@ class MessagesFlowNode(
|
||||||
}
|
}
|
||||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
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 navigateToPinnedMessagesList()
|
||||||
fun navigateToKnockRequestsList()
|
fun navigateToKnockRequestsList()
|
||||||
fun navigateToDeveloperSettings()
|
fun navigateToDeveloperSettings()
|
||||||
|
|
||||||
|
fun navigateToThreadsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
|
|
@ -299,6 +301,7 @@ class MessagesNode(
|
||||||
onViewRequestsClick = callback::navigateToKnockRequestsList,
|
onViewRequestsClick = callback::navigateToKnockRequestsList,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onThreadsListClick = callback::navigateToThreadsList,
|
||||||
)
|
)
|
||||||
roomMemberModerationRenderer.Render(
|
roomMemberModerationRenderer.Render(
|
||||||
state = state.roomMemberModerationState,
|
state = state.roomMemberModerationState,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
|
@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||||
import io.element.android.appconfig.MessageComposerConfig
|
import io.element.android.appconfig.MessageComposerConfig
|
||||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
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.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
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.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -160,6 +165,13 @@ class MessagesPresenter(
|
||||||
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
||||||
val roomCallState = roomCallStatePresenter.present()
|
val roomCallState = roomCallStatePresenter.present()
|
||||||
val roomMemberModerationState = roomMemberModerationPresenter.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 ->
|
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||||
perms.userEventPermissions()
|
perms.userEventPermissions()
|
||||||
|
|
@ -294,6 +306,11 @@ class MessagesPresenter(
|
||||||
roomMemberModerationState = roomMemberModerationState,
|
roomMemberModerationState = roomMemberModerationState,
|
||||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||||
successorRoom = roomInfo.successorRoom,
|
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,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,15 @@ data class MessagesState(
|
||||||
/** Type of "shared history" icon to show in the top bar. */
|
/** Type of "shared history" icon to show in the top bar. */
|
||||||
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
||||||
val successorRoom: SuccessorRoom?,
|
val successorRoom: SuccessorRoom?,
|
||||||
|
val threads: Threads,
|
||||||
val eventSink: (MessagesEvent) -> Unit
|
val eventSink: (MessagesEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val isTombstoned = successorRoom != null
|
val isTombstoned = successorRoom != null
|
||||||
|
|
||||||
|
data class Threads(
|
||||||
|
val hasThreads: Boolean,
|
||||||
|
val hasUnreadThreads: Boolean,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type of "shared history" icon to show in the top bar. */
|
/** 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