diff --git a/.github/renovate.json5 b/.github/renovate.json5
index c16310cf43..86ab2f1b1e 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -7,7 +7,6 @@
"PR-Dependencies",
],
"ignoreDeps": [
- "string:app_name",
"gradle",
],
"minimumReleaseAge": "7 days",
diff --git a/.gitignore b/.gitignore
index 77acaadb20..a50ae12c6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,9 +31,6 @@ __pycache__/
# Local configuration file (sdk path, etc)
local.properties
-# Proguard folder generated by Eclipse
-proguard/
-
# Log Files
*.log
diff --git a/.idea/copyright/Element_Enterprise.xml b/.idea/copyright/Element_Enterprise.xml
index b556049368..f7637c76a5 100644
--- a/.idea/copyright/Element_Enterprise.xml
+++ b/.idea/copyright/Element_Enterprise.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml
deleted file mode 100644
index b07fa5cc09..0000000000
--- a/.maestro/tests/account/changeServer.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-appId: ${MAESTRO_APP_ID}
----
-- tapOn:
- id: "login-change_server"
-- takeScreenshot: build/maestro/200-ChangeServer
-- tapOn: "matrix.org"
-- tapOn:
- id: "login-change_server"
-- tapOn: "Other"
-- tapOn:
- id: "change_server-server"
-- inputText: "element"
-- hideKeyboard
-- extendedWaitUntil:
- visible: "element.io"
- timeout: 10000
-- tapOn: "element.io"
-# Revert to matrix.org
-- tapOn:
- id: "login-change_server"
-- tapOn: "matrix.org"
diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml
index 873843b22d..81922c7f38 100644
--- a/.maestro/tests/account/login.yaml
+++ b/.maestro/tests/account/login.yaml
@@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
-- runFlow: changeServer.yaml
-- runFlow: ../assertions/assertLoginDisplayed.yaml
+# The account provider field defaults to matrix.org, so continue straight to the MAS login.
- tapOn:
id: "login-continue"
## MAS page
diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml
index 6d8558c38e..c32f23c323 100644
--- a/.maestro/tests/assertions/assertLoginDisplayed.yaml
+++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml
@@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
- visible: "Change account provider"
+ visible: "Enter your account provider"
timeout: 10000
diff --git a/AGENTS.md b/AGENTS.md
index d206be98c6..2c6cc9d60c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,6 +39,7 @@ PRs must meet these rules.
- Use `@PreviewsDayNight` for consistency.
- Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states.
- Wrap previews in `ElementPreview { ... }`.
+- When writing tests, never try to record the screenshots, the CI will do it.
---
diff --git a/CHANGES.md b/CHANGES.md
index 2955fc8ad7..db18e59251 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,86 @@
+Changes in Element X v26.08.2
+=============================
+
+
+
+**Note: this release in an internal release, it will not be pushed to the stores.**
+
+## What's Changed
+### ✨ Features
+* Remove the legacy web-based account creation for matrix.org by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7412
+### 🙌 Improvements
+* Move the custom tile server URL config to the Element wellknown content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7416
+* Explain a rate limiting push gateway in the notification troubleshooter by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7448
+* Move the `:libraries:wellknown` logic into `enterprise` modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7429
+* Refresh the room members when a mention is started by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7438
+* Close the invite screen once the invite has been declined by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7440
+* Use Profiles sliding sync extension check for own profile subscription by @ganfra in https://github.com/element-hq/element-x-android/pull/7403
+* Simplify choosing the account provider during sign in, with autocomplete of previously used ones by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7394
+### 🐛 Bugfixes
+* Fix wrong TextComposer placeholder displayed when in a thread. by @bmarty in https://github.com/element-hq/element-x-android/pull/7413
+* Fix bug reports failing to send after the app has crashed by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7395
+* Fix outgoing session verification stuck with incomplete recovery setup by @hardcore-sushi in https://github.com/element-hq/element-x-android/pull/7433
+* Keep transparency when an image is shared into the app with an imprecise mime type by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7452
+* Don't restart the sync service if the client is shutting down by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7472
+* Normalize the applicationName used in user agent by @bmarty in https://github.com/element-hq/element-x-android/pull/7477
+* Keep playing a voice message when another app plays a short sound by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7454
+* Keep the send button when the composer is emptied while editing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7456
+* Do not open the room again when the app is resumed from the recents list by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7436
+* Stop waiting for a room alias that the remote server never resolves by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7446
+### 🧱 Build
+* build-rust-sdk: print the command we run by @richvdh in https://github.com/element-hq/element-x-android/pull/7407
+* Remove license check on Pro modules. by @bmarty in https://github.com/element-hq/element-x-android/pull/7418
+* Improve build time config and update the Firebase configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/7473
+* Make the PUSHER_APP_ID configurable at build time. by @bmarty in https://github.com/element-hq/element-x-android/pull/7478
+### 📄 Documentation
+* Add KDoc to all the interfaces of the api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/7426
+### 🚧 In development 🚧
+* [LinkNewDevice] Ensure the notice screen is also protected by the timeout. by @bmarty in https://github.com/element-hq/element-x-android/pull/7428
+### Dependency upgrades
+* Update kotlin to v2.3.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7396
+* Update plugin sonarqube to v7.4.0.8496 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7415
+* Update metro to v1.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7421
+* Update roborazzi to v1.71.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7423
+* Update dependency io.sentry:sentry-android to v8.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7414
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7420
+* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7431
+* Update dependency org.maplibre.compose:maplibre-compose to v0.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7432
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7460
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7479
+* Update media3 to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7427
+* Update dependency androidx.compose:compose-bom to v2026.08.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7483
+### 💼 Element Pro
+* Add hooks to allow providing additional feature flags in EX pro by @richvdh in https://github.com/element-hq/element-x-android/pull/7419
+* Use the right base URL for the ESS config/well known endpoints by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7469
+### Others
+* Do not display pinned events that the client cannot render by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7379
+* Hide the forward action on live location shares by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7343
+* Do not fail the screenshot recording job when there is nothing to commit by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7355
+* Show the account a UnifiedPush registration belongs to in the distributor by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7345
+* Stop repeating messages that are already shown in a room notification by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7370
+* Turn a matrix: URI in a message into a pill by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7354
+* Send a message with Enter on a physical keyboard, Shift+Enter for a new line by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7353
+* Do not group timeline messages sent more than five minutes apart by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7349
+* Linkify URLs whose host is a bracketed IPv6 address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7347
+* Add missing tests for `RustTemporaryMatrixClient` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7417
+* Revert "Keep manually revealed media visible when re-entering a room" by @bmarty in https://github.com/element-hq/element-x-android/pull/7424
+* Fix outdated documentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/7430
+* Sync strings - New language supported: Arabic by @bmarty in https://github.com/element-hq/element-x-android/pull/7461
+* Keep the focused login field visible above the keyboard by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7455
+* Relax image compression so photos and screenshots keep more detail by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7451
+* Open the user profile of a user id that contains a slash by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7445
+* Remove the F-Droid dex patching step from the release script by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7442
+* Convert markdown to HTML when a caption is edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7441
+* Exclude carriage returns from the login and password fields by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7439
+* Show an error when the room to leave cannot be found by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7437
+* Give the room name in the timeline top bar a 48dp tap target by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7450
+* Model the fake voice recorder on a cancellable recording job by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7466
+
+## New Contributors
+* @hardcore-sushi made their first contribution in https://github.com/element-hq/element-x-android/pull/7433
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.1...v26.08.2
+
Changes in Element X v26.08.1
=============================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f2c7d37baa..f85d8dc33c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -127,24 +127,11 @@ android {
optimization {
enable = true
keepRules {
- files.add(File(projectDir, "common-proguard-rules.pro"))
- files.add(getDefaultProguardFile("proguard-android-optimize.txt"))
-
- // Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
- val flavorProguardFile = if (isEnterpriseBuild) {
- // Custom rules for enterprise builds
- File(projectDir, "enterprise-proguard-rules.pro")
- } else {
- // These default rules prevent the OSS app from being obfuscated
- File(projectDir, "default-proguard-rules.pro")
- }
-
- if (flavorProguardFile.exists()) {
- files.add(flavorProguardFile)
- } else {
- logger.warn("Proguard file ${flavorProguardFile.absolutePath} does not exist")
- }
+ // Equivalent of adding `getDefaultProguardFile("proguard-android-optimize.txt")` (this is the default value).
+ includeDefault = true
}
+ // Our custom keep rules are registered as `keepRules` source folders in the `androidComponents` block below,
+ // as the former `keepRules.files` DSL is deprecated since AGP 9.
}
}
@@ -229,6 +216,30 @@ androidComponents {
)
onVariants { variant ->
+ // Register the R8 keep rules source folders for optimized build types (release, nightly).
+ // Replaces the deprecated `optimization.keepRules.files` DSL (AGP 9+).
+ if (variant.buildType != "debug") {
+ variant.sources.keepRules?.let { keepRules ->
+ // Common rules, always applied.
+ keepRules.addStaticSourceDirectory("proguard/common")
+
+ // Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
+ val flavorProguardDir = if (isEnterpriseBuild) {
+ // Custom rules for enterprise builds
+ "../enterprise/proguard"
+ } else {
+ // Custom fules for FOSS builds
+ "proguard/foss"
+ }
+
+ if (File(projectDir, flavorProguardDir).exists()) {
+ keepRules.addStaticSourceDirectory(flavorProguardDir)
+ } else {
+ logger.warn("Proguard folder ${File(projectDir, flavorProguardDir).absolutePath} does not exist")
+ }
+ }
+ }
+
// Assigns a different version code for each output APK
// other than the universal APK.
variant.outputs.forEach { output ->
@@ -259,7 +270,6 @@ dependencies {
if (isEnterpriseBuild) {
allEnterpriseImpl(project)
implementation(projects.appicon.enterprise)
- implementation(project(":enterprise:features:enterprise:shared"))
} else {
implementation(projects.features.enterprise.implFoss)
implementation(projects.appicon.element)
diff --git a/app/common-proguard-rules.pro b/app/proguard/common/common-proguard-rules.keep
similarity index 100%
rename from app/common-proguard-rules.pro
rename to app/proguard/common/common-proguard-rules.keep
diff --git a/app/default-proguard-rules.pro b/app/proguard/foss/default-proguard-rules.keep
similarity index 100%
rename from app/default-proguard-rules.pro
rename to app/proguard/foss/default-proguard-rules.keep
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt
deleted file mode 100644
index d36fb742b0..0000000000
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.appconfig
-
-object PushConfig {
- /**
- * Note: pusher_app_id cannot exceed 64 chars.
- */
- const val PUSHER_APP_ID: String = "im.vector.app.android"
-}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index 054e9525c7..12b95d9a09 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -61,17 +61,23 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
+ testImplementation(projects.libraries.accountselect.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.features.announcement.test)
testImplementation(projects.features.forward.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test)
+ testImplementation(projects.features.preferences.test)
testImplementation(projects.features.rageshake.test)
+ testImplementation(projects.features.signedout.test)
testImplementation(projects.services.apperror.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index cd071131d9..dab9b7828c 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -335,11 +335,11 @@ class LoggedInFlowNode(
}
NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback {
- override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
+ override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) {
lifecycleScope.launch {
attachRoom(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
- initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom),
+ initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom, eventId = eventId),
clearBackstack = false,
)
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index acf7b66db9..c9deaeb3b3 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -32,7 +32,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
-import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.room.RoomFlowNode
@@ -40,6 +39,7 @@ import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
+import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
@@ -109,6 +109,13 @@ class RootFlowNode(
buildContext = buildContext,
plugins = plugins
) {
+ /**
+ * Login params coming from a launch or new [Intent], waiting to be consumed by the not logged in flow.
+ * Kept here so that the root nav target can be computed from both the logged in state and the pending
+ * login params, whatever the order in which the intent and the first nav state emission are processed.
+ */
+ private var pendingLoginParams: LoginParams? = null
+
override fun onBuilt() {
analyticsColdStartWatcher.start()
appCoroutineScope.launch {
@@ -152,7 +159,7 @@ class RootFlowNode(
}
}
LoggedInState.NotLoggedIn -> {
- switchToNotLoggedInFlow(null)
+ switchToNotLoggedInFlow(pendingLoginParams)
}
}
}
@@ -194,15 +201,18 @@ class RootFlowNode(
}
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
+ pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
private fun switchToNotLoggedInFlow(params: LoginParams?) {
+ Timber.d("switchToNotLoggedInFlow, hasLoginParams=${params != null}")
matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
+ pendingLoginParams = null
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
}
@@ -319,6 +329,7 @@ class RootFlowNode(
}
override fun onDone() {
+ pendingLoginParams = null
backstack.pop()
}
}
@@ -418,6 +429,7 @@ class RootFlowNode(
Timber.w("Login link ignored, multi account is disabled")
}
} else {
+ pendingLoginParams = params
switchToNotLoggedInFlow(params)
}
} else {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
index ee316f00aa..5d3a85fa9a 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -76,6 +76,7 @@ class IntentResolver(
}
private fun Intent.canBeIgnored(): Boolean {
+ if (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return true
return action == Intent.ACTION_MAIN &&
categories?.contains(Intent.CATEGORY_LAUNCHER) == true
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
index 9a91e9765b..8b02752a64 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
@@ -11,7 +11,7 @@ package io.element.android.appnav.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
-import io.element.android.appnav.di.MatrixSessionCache
+import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt
similarity index 98%
rename from appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
rename to appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt
index 11337f2e0a..ffae5f2b17 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.appnav.di
+package io.element.android.appnav.session
import androidx.annotation.VisibleForTesting
import com.bumble.appyx.core.state.MutableSavedStateMap
@@ -115,7 +115,7 @@ class MatrixSessionCache(
private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create(
- syncService = matrixClient.syncService,
+ matrixClient = matrixClient,
sessionCoroutineScope = matrixClient.sessionCoroutineScope,
)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt
similarity index 91%
rename from appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
rename to appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt
index 1ce423a569..3a72c10ab7 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.appnav.di
+package io.element.android.appnav.session
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
@@ -16,7 +16,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
-import io.element.android.libraries.matrix.api.sync.SyncService
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
@@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class SyncOrchestrator(
- @Assisted private val syncService: SyncService,
+ @Assisted private val matrixClient: MatrixClient,
@Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
@@ -47,13 +47,15 @@ class SyncOrchestrator(
@AssistedFactory
interface Factory {
fun create(
- syncService: SyncService,
+ matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator
}
private val tag = "SyncOrchestrator"
+ private val syncService = matrixClient.syncService
+
private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false)
@@ -94,7 +96,7 @@ class SyncOrchestrator(
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
- appForegroundStateService.isSharingLiveLocation
+ appForegroundStateService.isSharingLiveLocation,
)
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine(
@@ -103,6 +105,11 @@ class SyncOrchestrator(
networkMonitor.connectivity,
isAppActiveFlow,
) { syncState, networkState, isAppActive ->
+ if (matrixClient.isShuttingDown) {
+ Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again")
+ return@combine SyncStateAction.NoOp
+ }
+
val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt
new file mode 100644
index 0000000000..fea30c8019
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.appnav
+
+import android.content.Intent
+import android.net.Uri
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.bumble.appyx.core.modality.AncestryInfo
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.navmodel.backstack.activeElement
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
+import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
+import com.google.common.truth.Truth.assertThat
+import io.element.android.appnav.intent.IntentResolver
+import io.element.android.appnav.root.RootNavStateFlowFactory
+import io.element.android.appnav.root.RootPresenter
+import io.element.android.appnav.session.FakeSyncOrchestratorFactory
+import io.element.android.appnav.session.MatrixSessionCache
+import io.element.android.features.login.api.LoginEntryPoint
+import io.element.android.features.login.api.LoginParams
+import io.element.android.features.login.test.FakeLoginEntryPoint
+import io.element.android.features.login.test.FakeLoginIntentResolver
+import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
+import io.element.android.features.preferences.test.FakeCacheService
+import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
+import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
+import io.element.android.features.share.test.FakeShareIntentHandler
+import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
+import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
+import io.element.android.libraries.architecture.AssistedNodeFactory
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.test.FakeSdkMetadata
+import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
+import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
+import io.element.android.services.apperror.test.FakeAppErrorStateService
+import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
+import io.element.android.tests.testutils.node.FakeParentNode
+import io.element.android.tests.testutils.presenter.NotUsedPresenter
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com"
+
+private val A_LOGIN_PARAMS = LoginParams(
+ accountProvider = "example.com",
+ loginHint = "mxid:@alice:example.com",
+)
+
+private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params(
+ accountProvider = A_LOGIN_PARAMS.accountProvider,
+ loginHint = A_LOGIN_PARAMS.loginHint,
+)
+
+class RootFlowNodeTest : RobolectricTest() {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Test
+ fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest {
+ var loginEntryPointParams: LoginEntryPoint.Params? = null
+ val rootFlowNode = createRootFlowNode(
+ loginEntryPoint = FakeLoginEntryPoint { buildContext, params ->
+ loginEntryPointParams = params
+ node(buildContext) {}
+ },
+ )
+ rootFlowNode.parentNodeTestHelper()
+ // The intent is handled first, this is what happens when the deep link cold starts the app.
+ rootFlowNode.handleIntent(aLoginIntent())
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
+ // Then the first nav state emission lands, it must not override the login params.
+ runCurrent()
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
+ assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS)
+ }
+
+ @Test
+ fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest {
+ var loginEntryPointParams: LoginEntryPoint.Params? = null
+ val rootFlowNode = createRootFlowNode(
+ loginEntryPoint = FakeLoginEntryPoint { buildContext, params ->
+ loginEntryPointParams = params
+ node(buildContext) {}
+ },
+ )
+ rootFlowNode.parentNodeTestHelper()
+ // The nav state is observed first, this is what happens when the app is already running.
+ runCurrent()
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(null))
+ rootFlowNode.handleIntent(aLoginIntent())
+ runCurrent()
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
+ assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS)
+ }
+
+ private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK))
+
+ private fun TestScope.createRootFlowNode(
+ loginEntryPoint: LoginEntryPoint,
+ sessionStore: SessionStore = InMemorySessionStore(),
+ ): RootFlowNode {
+ val matrixSessionCache = MatrixSessionCache(
+ authenticationService = FakeMatrixAuthenticationService(),
+ syncOrchestratorFactory = FakeSyncOrchestratorFactory(),
+ analyticsService = FakeAnalyticsService(),
+ )
+ val parentNode = FakeParentNode(
+ graph = FakeNodeFactoriesBindings(
+ mapOf(
+ NotLoggedInFlowNode::class to AssistedNodeFactory { buildContext, plugins ->
+ NotLoggedInFlowNode(
+ buildContext = buildContext,
+ plugins = plugins,
+ loginEntryPoint = loginEntryPoint,
+ imageLoaderHolder = FakeImageLoaderHolder(),
+ analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
+ )
+ }
+ )
+ )
+ )
+ return RootFlowNode(
+ buildContext = BuildContext(
+ ancestryInfo = AncestryInfo.Child(anchor = parentNode),
+ savedStateMap = null,
+ customisations = NodeCustomisationDirectoryImpl(),
+ ),
+ plugins = emptyList(),
+ sessionStore = sessionStore,
+ accountProviderAccessControl = FakeAccountProviderAccessControl { true },
+ navStateFlowFactory = RootNavStateFlowFactory(
+ sessionStore = sessionStore,
+ cacheService = FakeCacheService(),
+ matrixSessionCache = matrixSessionCache,
+ imageLoaderHolder = FakeImageLoaderHolder(),
+ sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(),
+ ),
+ matrixSessionCache = matrixSessionCache,
+ presenter = RootPresenter(
+ crashDetectionPresenter = NotUsedPresenter(),
+ rageshakeDetectionPresenter = NotUsedPresenter(),
+ appErrorStateService = FakeAppErrorStateService(),
+ analyticsService = FakeAnalyticsService(),
+ sdkMetadata = FakeSdkMetadata("sha"),
+ ),
+ bugReportEntryPoint = FakeBugReportEntryPoint(),
+ signedOutEntryPoint = FakeSignedOutEntryPoint(),
+ accountSelectEntryPoint = FakeAccountSelectEntryPoint(),
+ intentResolver = IntentResolver(
+ deeplinkParser = { null },
+ loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS },
+ oAuthIntentResolver = FakeOAuthIntentResolver { null },
+ permalinkParser = FakePermalinkParser(),
+ shareIntentHandler = FakeShareIntentHandler(),
+ ),
+ oAuthActionFlow = FakeOAuthActionFlow(),
+ featureFlagService = FakeFeatureFlagService(),
+ announcementService = FakeAnnouncementService(),
+ analyticsService = FakeAnalyticsService(),
+ analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
+ appCoroutineScope = backgroundScope,
+ )
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
index 7309ca6ac2..32d0ec8f56 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
@@ -8,10 +8,11 @@
package io.element.android.appnav
-import io.element.android.appnav.di.SyncOrchestrator
+import io.element.android.appnav.session.SyncOrchestrator
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@@ -40,7 +41,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -60,7 +61,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -85,7 +86,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -117,7 +118,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -165,7 +166,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -208,7 +209,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -252,7 +253,7 @@ class SyncOrchestratorTest {
initialHasRingingCall = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -296,7 +297,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = true,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -339,7 +340,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -369,7 +370,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -381,12 +382,44 @@ class SyncOrchestratorTest {
startSyncRecorder.assertions().isNeverCalled()
}
+ @Test
+ fun `when the client is shutting down, the orchestrator does nothing`() = runTest {
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = false,
+ initialIsSyncingNotificationEventValue = false,
+ initialIsInCallValue = false,
+ )
+
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ }
+ val syncOrchestrator = createSyncOrchestrator(
+ matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }),
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // These should still not trigger a sync, sync the client is shutting down
+ networkMonitor.givenNetworkBlocked(false)
+ appForegroundStateService.updateIsInCallState(true)
+ appForegroundStateService.isInForeground.value = true
+
+ advanceTimeBy(10.seconds)
+
+ startSyncRecorder.assertions().isNeverCalled()
+ }
+
private fun TestScope.createSyncOrchestrator(
- syncService: FakeSyncService = FakeSyncService(),
+ matrixClient: FakeMatrixClient = FakeMatrixClient(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator(
- syncService = syncService,
+ matrixClient = matrixClient,
sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index 841f79678b..ddeae41a78 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -45,6 +45,24 @@ class IntentResolverTest : RobolectricTest() {
assertThat(result).isNull()
}
+ @Test
+ fun `resolve intent replayed from the history should return null`() {
+ val sut = createIntentResolver(
+ deeplinkParserResult = DeeplinkData.Room(
+ sessionId = A_SESSION_ID,
+ roomId = A_ROOM_ID,
+ threadId = null,
+ eventId = null,
+ )
+ )
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isNull()
+ }
+
@Test
fun `test resolve navigation intent root`() {
val sut = createIntentResolver(
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt
new file mode 100644
index 0000000000..586c8f0804
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.session
+
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.CoroutineScope
+
+class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory {
+ override fun create(
+ matrixClient: MatrixClient,
+ sessionCoroutineScope: CoroutineScope,
+ ): SyncOrchestrator = lambdaError()
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt
similarity index 97%
rename from appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
rename to appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt
index 36fa471dd0..bf315ddb01 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt
@@ -6,12 +6,12 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.appnav.di
+package io.element.android.appnav.session
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
-import io.element.android.libraries.matrix.api.sync.SyncService
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
@@ -138,11 +138,11 @@ class MatrixSessionCacheTest {
return object : SyncOrchestrator.Factory {
override fun create(
- syncService: SyncService,
+ matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator {
return SyncOrchestrator(
- syncService = syncService,
+ matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
diff --git a/build.gradle.kts b/build.gradle.kts
index 0e0df9dbc6..6bbc568c66 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -98,7 +98,7 @@ allprojects {
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
- allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
+ allWarningsAsErrors = findProperty("allWarningsAsErrors") == "true"
// Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
diff --git a/enterprise b/enterprise
index 3bf0df1f25..ae0001a17d 160000
--- a/enterprise
+++ b/enterprise
@@ -1 +1 @@
-Subproject commit 3bf0df1f25c4df8ee6cc226b00d9b6a675b37a09
+Subproject commit ae0001a17d503c9051704eeb6d7ff06609f87242
diff --git a/fastlane/metadata/android/en-US/changelogs/202608020.txt b/fastlane/metadata/android/en-US/changelogs/202608020.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202608020.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt
index 1af13e148e..2053ff5102 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt
@@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvent {
+ data object OnCallStarted : PictureInPictureEvent
data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
+ data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
index 2dc16d7f26..aeb507f6ec 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
@@ -38,6 +38,9 @@ class PictureInPicturePresenter(
fun handleEvent(event: PictureInPictureEvent) {
when (event) {
+ is PictureInPictureEvent.OnCallStarted -> {
+ pipController?.setupPipCallbacks()
+ }
is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController
}
@@ -55,6 +58,10 @@ class PictureInPicturePresenter(
pipController?.exitPip()
}
}
+ is PictureInPictureEvent.SetPipOrientation -> {
+ Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}")
+ pipView?.setPipOrientation(event.orientation)
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
index 90f74a37fd..aa1ab79dd0 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.pip
interface PipView {
+ fun setPipOrientation(orientation: Int?)
fun setPipParams()
fun enterPipMode(): Boolean
fun hangUp()
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
index 2537eb739c..ee63e273e4 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
@@ -118,6 +118,7 @@ internal fun CallScreenView(
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
+ pipState.eventSink(PictureInPictureEvent.OnCallStarted)
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
@@ -130,7 +131,12 @@ internal fun CallScreenView(
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
- val pipController = WebViewPipController(webView)
+ val pipController = WebViewPipController(
+ webView = webView,
+ updatePipOrientation = { orientation ->
+ pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation))
+ }
+ )
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
index 9a093ff5a6..5ed9e29fe2 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
@@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui
import android.Manifest
import android.app.PictureInPictureParams
import android.content.Intent
+import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
-import android.util.Rational
import android.view.WindowManager
import android.webkit.PermissionRequest
import androidx.activity.compose.setContent
@@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
+import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.audio.api.AudioFocus
@@ -86,6 +87,8 @@ class ElementCallActivity :
private var eventSink: ((CallScreenEvent) -> Unit)? = null
+ private var currentPipOrientation: Int? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -199,6 +202,7 @@ class ElementCallActivity :
}
DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
+ setPipParams()
pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
@@ -296,11 +300,17 @@ class ElementCallActivity :
}
}
+ override fun setPipOrientation(orientation: Int?) {
+ currentPipOrientation = orientation
+ setPictureInPictureParams(getPictureInPictureParams())
+ }
+
@RequiresApi(Build.VERSION_CODES.O)
private fun getPictureInPictureParams(): PictureInPictureParams {
+ // Portrait for calls seems more appropriate as a fallback value
+ val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT
return PictureInPictureParams.Builder()
- // Portrait for calls seems more appropriate
- .setAspectRatio(Rational(3, 5))
+ .setAspectRatioFromOrientation(orientation)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
index b259816f9d..f7788ad520 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.utils
interface PipController {
+ fun setupPipCallbacks()
suspend fun canEnterPip(): Boolean
fun enterPip()
fun exitPip()
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
index e68e57118b..3290269971 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
@@ -250,7 +250,7 @@ class WebViewAudioManager(
hasRegisteredCallbacks = true
}
- }
+ },
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
index 12f8dfd00f..88648e84fa 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
@@ -8,7 +8,10 @@
package io.element.android.features.call.impl.utils
+import android.content.res.Configuration
+import android.webkit.JavascriptInterface
import android.webkit.WebView
+import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine
*/
class WebViewPipController(
private val webView: WebView,
+ updatePipOrientation: (orientation: Int?) -> Unit,
) : PipController {
+ init {
+ // Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it
+ webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge")
+ }
+
+ override fun setupPipCallbacks() {
+ Timber.d("Adding callback in controls.onPipMediaOrientationUpdate")
+ webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null)
+ }
+
override suspend fun canEnterPip(): Boolean {
return suspendCoroutine { continuation ->
webView.evaluateJavascript("controls.canEnterPip()") { result ->
@@ -36,3 +50,19 @@ class WebViewPipController(
webView.evaluateJavascript("controls.disablePip()", null)
}
}
+
+private class PipBridge(
+ private val onOrientationChange: (orientation: Int?) -> Unit,
+) {
+ @JavascriptInterface
+ fun setPipOrientation(orientation: String?) {
+ Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation")
+ // This callback is used to update the picture-in-picture orientation in the WebView when it changes
+ val orientation = when (orientation) {
+ "portrait" -> Configuration.ORIENTATION_PORTRAIT
+ "landscape" -> Configuration.ORIENTATION_LANDSCAPE
+ else -> return
+ }
+ onOrientationChange(orientation)
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
index 5153f79c31..f2eca681e9 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
@@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController
import io.element.android.tests.testutils.lambda.lambdaError
class FakePipController(
+ private val setupPipCallbacksResult: () -> Unit = { lambdaError() },
private val canEnterPipResult: () -> Boolean = { lambdaError() },
private val enterPipResult: () -> Unit = { lambdaError() },
private val exitPipResult: () -> Unit = { lambdaError() },
) : PipController {
+ override fun setupPipCallbacks() = setupPipCallbacksResult()
+
override suspend fun canEnterPip(): Boolean = canEnterPipResult()
override fun enterPip() = enterPipResult()
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
index 55e94312bf..adfcde2c38 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
@@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePipView(
private val setPipParamsResult: () -> Unit = { lambdaError() },
private val enterPipModeResult: () -> Boolean = { lambdaError() },
- private val handUpResult: () -> Unit = { lambdaError() }
+ private val handUpResult: () -> Unit = { lambdaError() },
+ private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() },
) : PipView {
override fun setPipParams() = setPipParamsResult()
override fun enterPipMode(): Boolean = enterPipModeResult()
override fun hangUp() = handUpResult()
+ override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation)
}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt
new file mode 100644
index 0000000000..bfb9fb538a
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api
+
+import io.element.android.libraries.matrix.api.MatrixClientBuilder
+import io.element.android.libraries.matrix.api.core.SessionId
+
+/**
+ * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
+ */
+fun interface ClientBuilderEnterpriseHook {
+ suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
+}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt
new file mode 100644
index 0000000000..e6d5c9aac1
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api
+
+import io.element.android.libraries.matrix.api.MatrixClient
+
+/**
+ * A hook that can be used to customize the [MatrixClient] for enterprise features.
+ */
+fun interface ClientEnterpriseHook {
+ suspend operator fun invoke(client: MatrixClient)
+}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
index 2f1cbe1187..2cb4a356be 100644
--- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
@@ -10,7 +10,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
-import io.element.android.libraries.matrix.api.UrlContentFetcher
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
@@ -31,10 +31,9 @@ interface EnterpriseService {
* Rewrites the authentication server URL when the deployment requires a different one from the one advertised.
*
* @param url the URL to rewrite.
- * @param homeserver the homeserver the URL belongs to.
* @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works.
*/
- suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
+ suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
/**
* Returns the list of homeservers the user is allowed to sign in to.
@@ -53,9 +52,9 @@ interface EnterpriseService {
/**
* Whether the given homeserver enforces the use of Element Pro or a derived app.
*
- * @param homeserverUrl the homeserver to check.
+ * @param serverName the homeserver to check.
*/
- suspend fun isElementProEnforced(homeserverUrl: String): Boolean
+ suspend fun isElementProEnforced(serverName: String): Boolean
/**
* Override the brand color.
@@ -99,14 +98,6 @@ interface EnterpriseService {
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
- /**
- * Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
- * Returns `null` when this build does not read its configuration from an ESS deployment.
- *
- * @param domain the server whose configuration endpoint is requested.
- */
- fun essConfigEndpointUrl(domain: String): String?
-
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt
index 5f687ff052..0cdcdebe0b 100644
--- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt
@@ -32,13 +32,13 @@ sealed interface RemoteEnterpriseConfigResult {
is Success -> data
is Outdated -> data
is Error -> null
- NotFound -> null
+ is NotFound -> null
}
fun upToDateDataOrNull(): T? = when (this) {
is Success -> data
is Outdated -> null
is Error -> null
- NotFound -> null
+ is NotFound -> null
}
}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt
new file mode 100644
index 0000000000..ee22e4399d
--- /dev/null
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.impl
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
+import io.element.android.libraries.matrix.api.MatrixClientBuilder
+import io.element.android.libraries.matrix.api.core.SessionId
+
+/**
+ * Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS.
+ */
+@ContributesBinding(AppScope::class)
+class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook {
+ override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
+ // No modification
+ return clientBuilder
+ }
+}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt
new file mode 100644
index 0000000000..b0c0152d94
--- /dev/null
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.impl
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.enterprise.api.ClientEnterpriseHook
+import io.element.android.libraries.matrix.api.MatrixClient
+
+/**
+ * Default implementation of [ClientEnterpriseHook] that does nothing for FOSS.
+ */
+@ContributesBinding(AppScope::class)
+class DefaultClientEnterpriseHook : ClientEnterpriseHook {
+ override suspend fun invoke(client: MatrixClient) {
+ // No-op
+ }
+}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
index d07c0db569..bb10550369 100644
--- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
@@ -14,7 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
-import io.element.android.libraries.matrix.api.UrlContentFetcher
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -22,10 +22,10 @@ import kotlinx.coroutines.flow.flowOf
@ContributesBinding(AppScope::class)
class DefaultEnterpriseService : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
- override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
+ override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
- override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = false
+ override suspend fun isElementProEnforced(serverName: String): Boolean = false
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@@ -45,6 +45,4 @@ class DefaultEnterpriseService : EnterpriseService {
}
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
-
- override fun essConfigEndpointUrl(domain: String): String? = null
}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt
deleted file mode 100644
index 4d4a800de9..0000000000
--- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright (c) 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.enterprise.impl.scanner
-
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.ContributesBinding
-import io.element.android.libraries.matrix.api.UrlContentFetcher
-import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
-
-/**
- * Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
- */
-@ContributesBinding(AppScope::class)
-class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
- override suspend fun getContentScannerUrl(sessionId: SessionId): Result = Result.success(null)
-
- @ContributesBinding(AppScope::class)
- class Factory : ContentScannerUrlProvider.Factory {
- override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
- return DefaultContentScannerUrlProvider()
- }
- }
-}
diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
index e346e5934a..a8efcb5873 100644
--- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
+++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
-import io.element.android.libraries.matrix.api.UrlContentFetcher
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@@ -30,8 +30,7 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
- private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
- private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
+ private val tweakMasUrlResult: (String, ClientUrlContentFetcher) -> String = { _, _ -> lambdaError() },
private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
@@ -41,8 +40,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
- override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
- tweakMasUrlResult(url, homeserver, urlContentFetcher)
+ override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String = simulateLongTask {
+ tweakMasUrlResult(url, urlContentFetcher)
}
override fun homeserverAllowList(): List {
@@ -53,8 +52,8 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl)
}
- override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = simulateLongTask {
- isElementProEnforcedResult(homeserverUrl)
+ override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
+ isElementProEnforcedResult(serverName)
}
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask {
@@ -85,8 +84,4 @@ class FakeEnterpriseService(
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
return getNoisyNotificationChannelIdResult(sessionId)
}
-
- override fun essConfigEndpointUrl(domain: String): String {
- return essConfigEndpointUrlResult(domain)
- }
}
diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
index 1a90245611..1c6a0a906e 100644
--- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
+++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
@@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -23,7 +24,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
- fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
+ fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom()
fun navigateToCreateSpace()
fun navigateToSettings()
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
index 00f285e3f4..20f27f67bd 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
@@ -183,6 +184,7 @@ class HomeFlowNode(
fun navigateToRoom(
roomId: RoomId,
+ eventId: EventId?,
) {
if (!loadingJoinedRoomJob.value.isUninitialized()) {
Timber.w("Already loading a room, ignoring navigateToRoom for $roomId")
@@ -195,7 +197,7 @@ class HomeFlowNode(
}.fold(
onSuccess = { joinedRoom ->
if (isActive) {
- callback.navigateToRoom(roomId, joinedRoom)
+ callback.navigateToRoom(roomId = roomId, eventId = eventId, joinedRoom = joinedRoom)
loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
delay(200.milliseconds)
@@ -205,7 +207,7 @@ class HomeFlowNode(
onFailure = {
// If the operation wasn't cancelled, navigate without the room, using the room id
if (it !is CancellationException) {
- callback.navigateToRoom(roomId, null)
+ callback.navigateToRoom(roomId = roomId, eventId = null, joinedRoom = null)
}
loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
@@ -233,7 +235,7 @@ class HomeFlowNode(
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
- onAcceptInviteSuccess = ::navigateToRoom,
+ onAcceptInviteSuccess = { navigateToRoom(roomId = it, eventId = null) },
onDeclineInviteSuccess = { },
modifier = Modifier
)
@@ -269,6 +271,11 @@ class HomeFlowNode(
parentNode = this,
buildContext = buildContext,
inviteData = navTarget.inviteData,
+ callback = object : DeclineInviteAndBlockEntryPoint.Callback {
+ override fun onDeclineSuccess() {
+ backstack.pop()
+ }
+ },
)
}
is NavTarget.SelectNewOwnersWhenLeavingRoom -> {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
index f5a2400f2f..97b43b17cc 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
@@ -55,6 +55,8 @@ import io.element.android.features.home.impl.roomlist.RoomListContextMenu
import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.search.GlobalSearchEvent
+import io.element.android.features.home.impl.search.GlobalSearchView
import io.element.android.features.home.impl.search.RoomListSearchView
import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
@@ -73,6 +75,7 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -80,7 +83,7 @@ import kotlinx.coroutines.launch
@Composable
fun HomeView(
homeState: HomeState,
- onRoomClick: (RoomId) -> Unit,
+ onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
@@ -122,22 +125,34 @@ fun HomeView(
state = homeState,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
- onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
+ onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) },
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
)
- // This overlaid view will only be visible when state.displaySearchResults is true
- RoomListSearchView(
- state = state.searchState,
- eventSink = state.eventSink,
- hideInvitesAvatars = state.hideInvitesAvatars,
- onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
- modifier = Modifier
- .fillMaxSize()
- .background(ElementTheme.colors.bgCanvasDefault)
- )
+
+ if (state.globalSearchState.isEnabled) {
+ GlobalSearchView(
+ state = state.globalSearchState,
+ onSelectSearchResult = { roomId, eventId -> if (firstThrottler.canHandle()) onRoomClick(roomId, eventId) },
+ modifier = Modifier
+ .fillMaxSize()
+ .background(ElementTheme.colors.bgCanvasDefault),
+ )
+ } else {
+ // This overlaid view will only be visible when state.displaySearchResults is true
+ RoomListSearchView(
+ state = state.searchState,
+ eventSink = state.eventSink,
+ hideInvitesAvatars = state.hideInvitesAvatars,
+ onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) },
+ modifier = Modifier
+ .fillMaxSize()
+ .background(ElementTheme.colors.bgCanvasDefault)
+ )
+ }
+
acceptDeclineInviteView()
}
}
@@ -186,8 +201,18 @@ private fun HomeScaffold(
selectedNavigationItem = state.currentHomeNavigationBarItem,
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
- areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
- onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) },
+ areSearchResultsDisplayed = if (roomListState.globalSearchState.isEnabled) {
+ roomListState.globalSearchState.isSearchActive
+ } else {
+ roomListState.searchState.isSearchActive
+ },
+ onToggleSearch = {
+ if (roomListState.globalSearchState.isEnabled) {
+ roomListState.globalSearchState.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ } else {
+ roomListState.eventSink(RoomListEvent.ToggleSearchResults)
+ }
+ },
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
@@ -349,7 +374,7 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal
internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview {
HomeView(
homeState = state,
- onRoomClick = {},
+ onRoomClick = { _, _ -> },
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
@@ -369,7 +394,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
internal fun HomeViewA11yPreview() = ElementPreview {
HomeView(
homeState = aHomeState(),
- onRoomClick = {},
+ onRoomClick = { _, _ -> },
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
index 1eeba4fff7..6b744ca4df 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
@@ -15,6 +15,8 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.roomlist.RoomListPresenter
import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.search.GlobalSearchPresenter
+import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchPresenter
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
@@ -36,4 +38,7 @@ interface RoomListModule {
@Binds
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter
+
+ @Binds
+ fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
index fe09bd7cd1..1c21177988 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
@@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.into
+import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
@@ -85,6 +86,7 @@ class RoomListPresenter(
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter,
+ private val globalSearchPresenter: Presenter,
private val featureFlagService: FeatureFlagService,
) : Presenter {
private val encryptionService = client.encryptionService
@@ -97,6 +99,7 @@ class RoomListPresenter(
val searchState = searchPresenter.present()
val spaceFiltersState = spaceFiltersPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+ val globalSearchState = globalSearchPresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -177,6 +180,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
+ globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
index e184a5a57f..4164c56e9d 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist
import androidx.compose.runtime.Immutable
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
@@ -27,6 +28,7 @@ data class RoomListState(
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
+ val globalSearchState: GlobalSearchState,
val spaceFiltersState: SpaceFiltersState,
val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
index 97600e0247..f5f6d6a4a8 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
@@ -16,7 +16,9 @@ import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.features.home.impl.model.anInviteSender
+import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.search.aGlobalSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
@@ -41,7 +43,10 @@ open class RoomListStateProvider : PreviewParameterProvider {
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
- aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
+ aRoomListState(
+ searchState = aRoomListSearchState(isSearchActive = true, query = "Test"),
+ globalSearchState = aGlobalSearchState(isEnabled = false),
+ ),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
@@ -53,6 +58,7 @@ internal fun aRoomListState(
declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
+ globalSearchState: GlobalSearchState = aGlobalSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
@@ -66,6 +72,7 @@ internal fun aRoomListState(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
+ globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt
new file mode 100644
index 0000000000..76d9147e94
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface GlobalSearchEvent {
+ object ClearQuery : GlobalSearchEvent
+ object ToggleSearchVisibility : GlobalSearchEvent
+ data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
+ data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt
new file mode 100644
index 0000000000..6e153344c9
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt
@@ -0,0 +1,301 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.dateformatter.api.DateFormatter
+import io.element.android.libraries.dateformatter.api.DateFormatterMode
+import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
+import io.element.android.libraries.matrix.api.search.MessageSearch
+import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
+import io.element.android.libraries.matrix.api.search.MessageSearchResult
+import io.element.android.libraries.matrix.api.search.MessageSearchService
+import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import io.element.android.libraries.matrix.ui.messages.toPlainText
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import kotlin.jvm.optionals.getOrElse
+import kotlin.time.Duration.Companion.milliseconds
+
+@Inject
+class GlobalSearchPresenter(
+ private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory,
+ private val messageSearchService: MessageSearchService,
+ private val featureFlagService: FeatureFlagService,
+ private val latestEventFormatter: RoomLatestEventFormatter,
+ private val dateFormatter: DateFormatter,
+ private val fileSizeFormatter: FileSizeFormatter,
+ private val permalinkParser: PermalinkParser,
+ private val coroutineDispatchers: CoroutineDispatchers,
+ private val matrixClient: MatrixClient,
+) : Presenter {
+ @Composable
+ override fun present(): GlobalSearchState {
+ val coroutineScope = rememberCoroutineScope()
+ val isEnabled by produceState(false) {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
+ }
+ val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
+ val queryState = rememberTextFieldState()
+ var isSearchActive by remember { mutableStateOf(false) }
+ var searchResults: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) }
+ var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
+ val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
+
+ LaunchedEffect(queryState.text) {
+ // Add a delay to avoid performing too many searches in a short period of time, which can be expensive
+ delay(200.milliseconds)
+
+ searchResults = if (queryState.text.isNotEmpty()) {
+ AsyncData.Loading(prevData = searchResults.dataOrNull())
+ } else {
+ AsyncData.Uninitialized
+ }
+
+ launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) }
+ launch {
+ currentMessageSearch.setQuery(queryState.text.toString())
+ .onFailure { Timber.e(it, "Could not set query for message search") }
+ }
+ }
+
+ LaunchedEffect(currentTarget) {
+ if (queryState.text.isNotEmpty()) {
+ searchResults = AsyncData.Loading()
+ }
+
+ when (currentTarget) {
+ GlobalSearchTarget.ROOMS -> {
+ roomListSearchDataSource.roomSummaries.collectLatest { results ->
+ Timber.d("Room list search found ${results.size} items")
+ // The room list data source always returns some values, even if the query is empty,
+ // so we only want to update the search results if the query is not empty
+ searchResults = if (queryState.text.isNotEmpty()) {
+ AsyncData.Success(GlobalSearchResults.RoomListResults(results = results))
+ } else {
+ AsyncData.Uninitialized
+ }
+ }
+ }
+ GlobalSearchTarget.MESSAGES -> {
+ Timber.d("Message search found ${currentMessageSearch.results.value.size} items")
+ combine(
+ currentMessageSearch.results,
+ currentMessageSearch.paginationState,
+ ) { results, paginationState ->
+ Pair(results, paginationState)
+ }.collectLatest { (results, paginationState) ->
+ Timber.d("Message search found ${results.size} items, pagination state: $paginationState")
+
+ // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each
+ // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
+ val mappedResults = results.map { result ->
+ async(coroutineDispatchers.computation) {
+ val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
+ val formattedTimestamp = dateFormatter.format(
+ timestamp = result.timestamp,
+ mode = DateFormatterMode.TimeOrDate,
+ useRelative = true
+ )
+
+ if (!result.content.isMediaContent()) {
+ mapMessageContent(result, roomInfo, formattedTimestamp)
+ } else {
+ mapMediaContent(result, roomInfo, formattedTimestamp)
+ }
+ }
+ }
+ .awaitAll()
+ .mapNotNull { it }
+
+ searchResults = when {
+ queryState.text.isEmpty() -> AsyncData.Uninitialized
+ mappedResults.isNotEmpty() -> {
+ AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList()))
+ }
+ paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> {
+ AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf()))
+ }
+ paginationState is MessageSearchPaginationState.Loading -> {
+ AsyncData.Loading(prevData = searchResults.dataOrNull())
+ }
+ else -> {
+ AsyncData.Uninitialized
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun handleEvent(event: GlobalSearchEvent) {
+ when (event) {
+ GlobalSearchEvent.ClearQuery -> {
+ queryState.clearText()
+ }
+ GlobalSearchEvent.ToggleSearchVisibility -> {
+ isSearchActive = !isSearchActive
+ queryState.clearText()
+ }
+ is GlobalSearchEvent.UpdateVisibleRange -> {
+ when (currentTarget) {
+ GlobalSearchTarget.ROOMS -> coroutineScope.launch {
+ // TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe
+ // to the room info for each room in the search results.
+ roomListSearchDataSource.updateVisibleRange(event.range)
+ }
+ GlobalSearchTarget.MESSAGES -> coroutineScope.launch {
+ val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0
+ val currentPaginationState = currentMessageSearch.paginationState.value
+ val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached
+ if (event.range.last >= currentCount - 10 && canPaginate) {
+ currentMessageSearch.paginate()
+ }
+ }
+ }
+ }
+ is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
+ }
+ }
+
+ return GlobalSearchState(
+ isEnabled = isEnabled,
+ isSearchActive = isSearchActive,
+ queryState = queryState,
+ currentTarget = currentTarget,
+ results = searchResults,
+ eventSink = ::handleEvent,
+ )
+ }
+
+ private fun mapMessageContent(
+ result: MessageSearchResult,
+ roomInfo: RoomInfo,
+ formattedTimestamp: String,
+ ): MessageSearchResultItem.Message? {
+ val body = latestEventFormatter.format(
+ latestEvent = LatestEventValue.Remote(
+ timestamp = result.timestamp,
+ content = result.content,
+ senderId = result.senderId,
+ senderProfile = result.senderProfile,
+ isOwn = matrixClient.isMe(result.senderId),
+ ),
+ isDmRoom = false,
+ )
+ return MessageSearchResultItem.Message(
+ messageSearchResult = result,
+ body = body?.toString() ?: "",
+ formattedTimestamp = formattedTimestamp,
+ roomInfo = roomInfo,
+ )
+ }
+
+ private fun mapMediaContent(
+ result: MessageSearchResult,
+ roomInfo: RoomInfo,
+ formattedTimestamp: String,
+ ): MessageSearchResultItem.Media? {
+ val messageType = when (val content = result.content) {
+ is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
+ else -> return null
+ }
+
+ val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename)
+
+ val thumbnailType = when (messageType) {
+ is ImageMessageType -> AttachmentThumbnailType.Image
+ is VideoMessageType -> AttachmentThumbnailType.Video
+ is AudioMessageType -> AttachmentThumbnailType.Audio
+ is VoiceMessageType -> AttachmentThumbnailType.Voice
+ is FileMessageType -> AttachmentThumbnailType.File
+ is StickerMessageType -> AttachmentThumbnailType.Image
+ }
+ val thumbnailSource = when (messageType) {
+ is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source
+ is VideoMessageType -> messageType.info?.thumbnailSource
+ is AudioMessageType -> null
+ is VoiceMessageType -> null
+ is FileMessageType -> null
+ is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source
+ }
+ val blurhash = when (messageType) {
+ is ImageMessageType -> messageType.info?.blurhash
+ is VideoMessageType -> messageType.info?.blurhash
+ is AudioMessageType -> null
+ is VoiceMessageType -> null
+ is FileMessageType -> null
+ is StickerMessageType -> messageType.info?.blurhash
+ }
+
+ val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase()
+ val formattedSize = when (messageType) {
+ is ImageMessageType -> messageType.info?.size
+ is VideoMessageType -> messageType.info?.size
+ is AudioMessageType -> messageType.info?.size
+ is VoiceMessageType -> messageType.info?.size
+ is FileMessageType -> messageType.info?.size
+ is StickerMessageType -> messageType.info?.size
+ }
+ ?.let(fileSizeFormatter::format)
+
+ val mediaContent = MediaSearchResultContent(
+ filename = messageType.filename,
+ extension = extension,
+ caption = caption,
+ formattedSize = formattedSize,
+ thumbnailSource = thumbnailSource,
+ thumbnailType = thumbnailType,
+ blurhash = blurhash
+ )
+
+ return MessageSearchResultItem.Media(
+ messageSearchResult = result,
+ mediaContent = mediaContent,
+ formattedTimestamp = formattedTimestamp,
+ roomInfo = roomInfo,
+ )
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt
new file mode 100644
index 0000000000..d623fd5594
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.Immutable
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.search.MessageSearchResult
+import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import kotlinx.collections.immutable.ImmutableList
+
+@Immutable
+data class GlobalSearchState(
+ val isEnabled: Boolean,
+ val isSearchActive: Boolean,
+ val queryState: TextFieldState,
+ val currentTarget: GlobalSearchTarget,
+ val results: AsyncData,
+ val eventSink: (GlobalSearchEvent) -> Unit
+)
+
+@Immutable
+enum class GlobalSearchTarget {
+ ROOMS,
+ MESSAGES,
+}
+
+@Immutable
+sealed interface GlobalSearchResults {
+ data class RoomListResults(val results: ImmutableList) : GlobalSearchResults
+ data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults
+
+ fun isEmpty(): Boolean = when (this) {
+ is RoomListResults -> results.isEmpty()
+ is MessageSearchResults -> results.isEmpty()
+ }
+}
+
+sealed interface MessageSearchResultItem {
+ val roomId: RoomId
+ val eventId: EventId
+ val senderId: UserId
+ val senderName: String
+ val roomInfo: RoomInfo
+ val formattedTimestamp: String
+
+ data class Message(
+ val messageSearchResult: MessageSearchResult,
+ val body: String,
+ override val roomInfo: RoomInfo,
+ override val formattedTimestamp: String,
+ ) : MessageSearchResultItem {
+ override val roomId: RoomId = messageSearchResult.roomId
+ override val eventId: EventId = messageSearchResult.eventId
+ override val senderId: UserId = messageSearchResult.senderId
+ override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
+ }
+
+ data class Media(
+ val messageSearchResult: MessageSearchResult,
+ val mediaContent: MediaSearchResultContent,
+ override val roomInfo: RoomInfo,
+ override val formattedTimestamp: String,
+ ) : MessageSearchResultItem {
+ override val roomId: RoomId = messageSearchResult.roomId
+ override val eventId: EventId = messageSearchResult.eventId
+ override val senderId: UserId = messageSearchResult.senderId
+ override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
+ }
+}
+
+data class MediaSearchResultContent(
+ val filename: String,
+ val extension: String?,
+ val caption: String?,
+ val formattedSize: String?,
+ val thumbnailSource: MediaSource?,
+ val thumbnailType: AttachmentThumbnailType,
+ val blurhash: String?,
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt
new file mode 100644
index 0000000000..29031837f2
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.room.CallIntentConsensus
+import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import io.element.android.libraries.matrix.api.room.join.JoinRule
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
+import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
+import io.element.android.libraries.matrix.api.search.MessageSearchResult
+import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
+import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentList
+
+class GlobalSearchStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ // Not enabled, no preview
+ aGlobalSearchState(isEnabled = false),
+ aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.ROOMS,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Loading(),
+ ),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.ROOMS,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Success(
+ GlobalSearchResults.RoomListResults(
+ results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
+ summary.copy(name = "Room with Query #${index + 1}")
+ }.toPersistentList()
+ )
+ ),
+ ),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.MESSAGES,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
+ MessageSearchResultItem.Message(
+ messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
+ body = "A message with Query",
+ roomInfo = aRoomInfo(),
+ formattedTimestamp = "12:00",
+ ),
+ MessageSearchResultItem.Media(
+ messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
+ mediaContent = MediaSearchResultContent(
+ filename = "file.png",
+ extension = "PNG",
+ caption = "A caption containing Query",
+ formattedSize = "1 MB",
+ thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
+ thumbnailType = AttachmentThumbnailType.Image,
+ blurhash = null,
+ ),
+ roomInfo = aRoomInfo(),
+ formattedTimestamp = "12:00",
+ ),
+ ))),
+ ),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.MESSAGES,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
+ ),
+ )
+}
+
+fun aRoomInfo(
+ id: RoomId = RoomId("!roomId:server.org"),
+ name: String? = "A room",
+ rawName: String? = "A room raw name",
+ topic: String? = "A room topic",
+ avatarUrl: String? = "https://example.com/avatar.png",
+ isPublic: Boolean = true,
+ isDirect: Boolean = false,
+ isEncrypted: Boolean = false,
+ joinRule: JoinRule? = JoinRule.Public,
+ isSpace: Boolean = false,
+ successorRoom: SuccessorRoom? = null,
+ isFavorite: Boolean = false,
+ canonicalAlias: RoomAlias? = null,
+ alternativeAliases: List = emptyList(),
+ currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
+ inviter: RoomMember? = null,
+ activeMembersCount: Long = 2,
+ invitedMembersCount: Long = 1,
+ joinedMembersCount: Long = 1,
+ highlightCount: Long = 0,
+ notificationCount: Long = 0,
+ userDefinedNotificationMode: RoomNotificationMode? = null,
+ hasRoomCall: Boolean = false,
+ roomPowerLevels: RoomPowerLevels? = RoomPowerLevels(
+ values = RoomPowerLevelsValues(
+ ban = 0,
+ invite = 0,
+ kick = 0,
+ eventsDefault = 0,
+ stateDefault = 0,
+ redactEvents = 0,
+ roomName = 0,
+ roomAvatar = 0,
+ roomTopic = 0,
+ spaceChild = 0,
+ beacon = 0,
+ beaconInfo = 0
+ ),
+ users = persistentMapOf(),
+ ),
+ activeRoomCallParticipants: List = emptyList(),
+ heroes: List = emptyList(),
+ pinnedEventIds: List = emptyList(),
+ roomCreators: List = emptyList(),
+ isMarkedUnread: Boolean = false,
+ numUnreadMessages: Long = 0,
+ numUnreadNotifications: Long = 0,
+ numUnreadMentions: Long = 0,
+ historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
+ roomVersion: String? = "11",
+ privilegedCreatorRole: Boolean = false,
+ isLowPriority: Boolean = false,
+ activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None,
+ isDm: Boolean = false,
+ fullyReadEventId: EventId? = null,
+) = RoomInfo(
+ id = id,
+ name = name,
+ rawName = rawName,
+ topic = topic,
+ avatarUrl = avatarUrl,
+ isPublic = isPublic,
+ isDirect = isDirect,
+ isEncrypted = isEncrypted,
+ joinRule = joinRule,
+ isSpace = isSpace,
+ successorRoom = successorRoom,
+ isFavorite = isFavorite,
+ canonicalAlias = canonicalAlias,
+ alternativeAliases = alternativeAliases.toImmutableList(),
+ currentUserMembership = currentUserMembership,
+ inviter = inviter,
+ activeMembersCount = activeMembersCount,
+ invitedMembersCount = invitedMembersCount,
+ joinedMembersCount = joinedMembersCount,
+ highlightCount = highlightCount,
+ notificationCount = notificationCount,
+ userDefinedNotificationMode = userDefinedNotificationMode,
+ hasRoomCall = hasRoomCall,
+ roomPowerLevels = roomPowerLevels,
+ activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
+ heroes = heroes.toImmutableList(),
+ pinnedEventIds = pinnedEventIds.toImmutableList(),
+ creators = roomCreators.toImmutableList(),
+ isMarkedUnread = isMarkedUnread,
+ numUnreadMessages = numUnreadMessages,
+ numUnreadNotifications = numUnreadNotifications,
+ numUnreadMentions = numUnreadMentions,
+ historyVisibility = historyVisibility,
+ roomVersion = roomVersion,
+ privilegedCreatorRole = privilegedCreatorRole,
+ isLowPriority = isLowPriority,
+ activeCallIntentConsensus = activeCallIntentConsensus,
+ isDm = isDm,
+ fullyReadEventId = fullyReadEventId,
+)
+
+fun aMessageContent(
+ body: String = "body",
+ inReplyTo: InReplyTo? = null,
+ isEdited: Boolean = false,
+ threadInfo: EventThreadInfo? = null,
+ messageType: MessageType = TextMessageType(
+ body = body,
+ formatted = null
+ )
+) = MessageContent(
+ body = body,
+ inReplyTo = inReplyTo,
+ isEdited = isEdited,
+ threadInfo = threadInfo,
+ type = messageType
+)
+
+internal fun aMessageSearchResult(
+ roomId: RoomId = RoomId("!roomId:server.org"),
+ eventId: EventId = EventId("\$eventId:server.org"),
+ senderId: UserId = UserId("@user:server.org"),
+ senderProfile: ProfileDetails = aProfileDetailsReady(),
+ content: MessageContent = aMessageContent(),
+ timestamp: Long = 0L,
+) = MessageSearchResult(
+ roomId = roomId,
+ eventId = eventId,
+ senderId = senderId,
+ senderProfile = senderProfile,
+ content = content,
+ timestamp = timestamp,
+)
+
+internal fun aGlobalSearchState(
+ isEnabled: Boolean = true,
+ isSearchActive: Boolean = false,
+ queryState: TextFieldState = TextFieldState(),
+ currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
+ results: AsyncData = AsyncData.Uninitialized,
+ eventSink: (GlobalSearchEvent) -> Unit = {},
+) = GlobalSearchState(
+ isEnabled = isEnabled,
+ isSearchActive = isSearchActive,
+ queryState = queryState,
+ currentTarget = currentTarget,
+ results = results,
+ eventSink = eventSink
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt
new file mode 100644
index 0000000000..593a5ed6bf
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt
@@ -0,0 +1,564 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.modifiers.niceClickable
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.text.roundToPx
+import io.element.android.libraries.designsystem.theme.components.FilledTextField
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.SegmentedButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
+import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
+import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun GlobalSearchView(
+ state: GlobalSearchState,
+ onSelectSearchResult: (RoomId, EventId?) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BackHandler(enabled = state.isSearchActive) {
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ }
+
+ AnimatedVisibility(
+ visible = state.isSearchActive,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Column(modifier = modifier) {
+ GlobalSearchContent(
+ state = state,
+ onBackButtonClick = {
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ },
+ onSelectSearchResult = onSelectSearchResult,
+ )
+ }
+ }
+}
+
+@Composable
+private fun GlobalSearchContent(
+ state: GlobalSearchState,
+ onBackButtonClick: () -> Unit,
+ onSelectSearchResult: (RoomId, EventId?) -> Unit,
+) {
+ val borderColor = MaterialTheme.colorScheme.tertiary
+ val strokeWidth = 1.dp
+
+ val drawBottomLineModifier = Modifier.drawBehind {
+ drawLine(
+ color = borderColor,
+ start = Offset(0f, size.height),
+ end = Offset(size.width, size.height),
+ strokeWidth = strokeWidth.value
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ navigationIcon = { BackButton(onClick = onBackButtonClick) },
+ title = {
+ // The stateSaver will keep the selection state when returning to this UI
+ val focusRequester = remember { FocusRequester() }
+ val searchLabel = stringResource(CommonStrings.action_search)
+ FilledTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .semantics { contentDescription = searchLabel },
+ state = state.queryState,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ errorIndicatorColor = Color.Transparent,
+ ),
+ trailingIcon = if (state.queryState.text.isNotEmpty()) {
+ @Composable {
+ IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
+ Icon(
+ imageVector = CompoundIcons.Close(),
+ contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
+ )
+ }
+ }
+ } else {
+ null
+ },
+ )
+
+ LaunchedEffect(Unit) {
+ if (!focusRequester.restoreFocusedChild()) {
+ focusRequester.requestFocus()
+ }
+ focusRequester.saveFocusedChild()
+ }
+ }
+ )
+ },
+ contentWindowInsets = scaffoldScrollableContentInsets,
+ ) { padding ->
+ Column(
+ modifier = Modifier.padding(padding),
+ ) {
+ SingleChoiceSegmentedButtonRow(
+ modifier = drawBottomLineModifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
+ ) {
+ SegmentedButton(
+ index = 0,
+ count = 2,
+ selected = state.currentTarget == GlobalSearchTarget.ROOMS,
+ onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
+ text = stringResource(R.string.search_section_chats),
+ )
+
+ SegmentedButton(
+ index = 1,
+ count = 2,
+ selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
+ onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
+ text = stringResource(R.string.search_section_messages),
+ )
+ }
+
+ val lazyListState = rememberLazyListState()
+ OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
+ state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = lazyColumnContentPadding,
+ state = lazyListState,
+ ) {
+ val results = state.results.dataOrNull()
+ when {
+ state.results.isUninitialized() -> startSearching()
+ state.results.isLoading() -> loading()
+ results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
+ results is GlobalSearchResults.RoomListResults -> roomListResults(
+ results = results.results,
+ onRoomClick = { roomId -> onSelectSearchResult(roomId, null) },
+ )
+ results is GlobalSearchResults.MessageSearchResults -> messageSearchResults(
+ results = results.results,
+ onSearchResultSelected = onSelectSearchResult,
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun LazyListScope.startSearching() {
+ item {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(40.dp),
+ title = stringResource(R.string.start_searching_title),
+ subTitle = stringResource(R.string.start_searching_subtitle),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
+ )
+ }
+}
+
+private fun LazyListScope.loading() {
+ item {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+}
+
+private fun LazyListScope.emptySearchResults(query: String) {
+ item {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(40.dp),
+ title = stringResource(R.string.search_no_results_title),
+ subTitle = stringResource(R.string.search_no_results_subtitle, query),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
+ )
+ }
+}
+
+private fun LazyListScope.roomListResults(
+ results: ImmutableList,
+ onRoomClick: (RoomId) -> Unit,
+) {
+ itemsIndexed(
+ items = results,
+ key = { _, roomSummary -> roomSummary.roomId },
+ ) { index, result ->
+ Row(
+ modifier = Modifier
+ .niceClickable(onClick = { onRoomClick(result.roomId) })
+ .fillMaxWidth()
+ .addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Avatar(
+ avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem),
+ avatarType = AvatarType.Room(
+ heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(),
+ isTombstoned = result.isTombstoned,
+ ),
+ )
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = result.name ?: result.roomId.value,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+
+ if (result.isDm) {
+ val userId = result.heroes.firstOrNull()?.id
+ userId?.let {
+ Text(
+ text = it,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ } else {
+ result.canonicalAlias?.let {
+ Text(
+ text = it.value,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun LazyListScope.messageSearchResults(
+ results: ImmutableList,
+ onSearchResultSelected: (RoomId, EventId?) -> Unit,
+) {
+ if (results.isNotEmpty()) {
+ item {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ text = stringResource(R.string.search_from_your_messages_header),
+ style = ElementTheme.typography.fontBodyMdMedium,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ itemsIndexed(
+ items = results,
+ contentType = { _, messageSearchResult -> messageSearchResult::class.java },
+ key = { _, messageSearchResult -> messageSearchResult.eventId },
+ ) { index, result ->
+ val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
+ when (result) {
+ is MessageSearchResultItem.Message -> TextMessageSearchResultItemView(
+ modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
+ avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
+ avatarType = AvatarType.Room(
+ heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
+ isTombstoned = result.roomInfo.successorRoom != null
+ ),
+ roomName = result.roomInfo.name ?: result.roomInfo.id.value,
+ body = result.body,
+ formattedTimestamp = result.formattedTimestamp,
+ )
+ is MessageSearchResultItem.Media -> {
+ MediaMessageSearchResultItemView(
+ modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
+ avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
+ avatarType = AvatarType.Room(
+ heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
+ isTombstoned = result.roomInfo.successorRoom != null
+ ),
+ roomName = result.roomInfo.name ?: result.roomInfo.id.value,
+ content = result.mediaContent,
+ formattedTimestamp = result.formattedTimestamp,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TextMessageSearchResultItemView(
+ avatarData: AvatarData,
+ avatarType: AvatarType,
+ roomName: String,
+ body: String,
+ formattedTimestamp: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Avatar(
+ avatarData = avatarData,
+ avatarType = avatarType,
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp, bottom = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = roomName,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ )
+ Text(
+ text = formattedTimestamp,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ Text(
+ text = body,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MediaMessageSearchResultItemView(
+ avatarData: AvatarData,
+ avatarType: AvatarType,
+ roomName: String,
+ content: MediaSearchResultContent,
+ formattedTimestamp: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Avatar(
+ avatarData = avatarData,
+ avatarType = avatarType,
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = roomName,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ )
+ Text(
+ text = formattedTimestamp,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+
+ content.caption?.let {
+ Text(
+ text = it,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(1f)
+ .background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AttachmentThumbnail(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ info = AttachmentThumbnailInfo(
+ type = content.thumbnailType,
+ thumbnailSource = content.thumbnailSource,
+ textContent = null,
+ blurHash = content.blurhash,
+ ),
+ thumbnailSize = 36.dp.roundToPx().toLong(),
+ backgroundColor = ElementTheme.colors.bgCanvasDefault,
+ )
+
+ Column {
+ Text(
+ text = content.filename,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ content.extension?.let {
+ Text(
+ text = it,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ content.formattedSize?.let {
+ Text(
+ text = "($it)",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier {
+ if (isLastItem) return this
+
+ return this.drawBehind {
+ val strokeWidthPx = 1.dp.toPx()
+ drawLine(
+ color = color,
+ start = Offset(0f, size.height - strokeWidthPx),
+ end = Offset(size.width, size.height - strokeWidthPx),
+ strokeWidth = strokeWidthPx,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun GlobalSearchViewPreview(
+ @PreviewParameter(GlobalSearchStateProvider::class) state: GlobalSearchState,
+) {
+ ElementPreview {
+ GlobalSearchView(
+ state = state,
+ onSelectSearchResult = { _, _ -> },
+ )
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
index 03fcb5520f..df7ed0dd37 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
@@ -46,6 +46,8 @@ class RoomListSearchDataSource(
coroutineScope = coroutineScope
)
+ val loadingState = roomList.loadingState
+
val roomSummaries: Flow> = roomList.summaries
.map { roomSummaries ->
roomSummaries
diff --git a/features/home/impl/src/main/res/values/temporary.xml b/features/home/impl/src/main/res/values/temporary.xml
new file mode 100644
index 0000000000..c44001e5a6
--- /dev/null
+++ b/features/home/impl/src/main/res/values/temporary.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ Start searching…
+ Search for chats and messages
+ Chats
+ From your messages
+ Messages
+ No results
+ There are no results for “%1$s”. Try a new search term.
+
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
index 8eee8e1ee2..c0cb5f7c01 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
@@ -11,6 +11,8 @@ package io.element.android.features.home.impl
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -36,14 +38,14 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
directLogoutView = { _ -> lambdaError() },
reportRoomEntryPoint = { _, _, _ -> lambdaError() },
- declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
+ declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
val callback = object : HomeEntryPoint.Callback {
- override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
+ override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError()
override fun navigateToCreateSpace() = lambdaError()
override fun navigateToSettings() = lambdaError()
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index 64dfc46ee2..d2e33c77c1 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -20,6 +20,7 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.model.createRoomListRoomSummary
import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.search.aGlobalSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
@@ -680,6 +681,7 @@ class RoomListPresenterTest {
analyticsService = FakeAnalyticsService(),
),
searchPresenter = searchPresenter,
+ globalSearchPresenter = { aGlobalSearchState() },
filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
index 773f428917..bd18cd903e 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
@@ -277,7 +277,7 @@ private fun AndroidComposeUiTest.setRoomListView(
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
- onRoomClick = onRoomClick,
+ onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt
new file mode 100644
index 0000000000..ef8d373630
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt
@@ -0,0 +1,305 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
+import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.dateformatter.api.DateFormatter
+import io.element.android.libraries.dateformatter.test.FakeDateFormatter
+import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
+import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.search.MessageSearchService
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
+import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.libraries.matrix.test.search.FakeMessageSearch
+import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import java.util.Optional
+
+class GlobalSearchPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ assertThat(state.isEnabled).isFalse()
+ assertThat(state.isSearchActive).isFalse()
+ assertThat(state.queryState.text.toString()).isEmpty()
+ assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
+ assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
+ }
+ }
+ }
+
+ @Test
+ fun `present - isEnabled reflects the feature flag`() = runTest {
+ val presenter = createGlobalSearchPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.MessageSearch.key to true),
+ ),
+ )
+ presenter.test {
+ // isEnabled is emitted asynchronously via produceState, so it starts as false and flips to true
+ var state = awaitItem()
+ while (!state.isEnabled) {
+ state = awaitItem()
+ }
+ assertThat(state.isEnabled).isTrue()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - toggle search visibility`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ assertThat(state.isSearchActive).isFalse()
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ }
+ awaitItem().let { state ->
+ assertThat(state.isSearchActive).isTrue()
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ }
+ awaitItem().let { state ->
+ assertThat(state.isSearchActive).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - update target`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
+ state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ }
+ awaitItem().let { state ->
+ assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.MESSAGES)
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - clear query`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.queryState.edit { append("A query") }
+ awaitItem().let { state ->
+ assertThat(state.queryState.text.toString()).isEqualTo("A query")
+ state.eventSink(GlobalSearchEvent.ClearQuery)
+ }
+ awaitItem().let { state ->
+ assertThat(state.queryState.text.toString()).isEmpty()
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - room search results are emitted when the room list changes`() = runTest {
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(createRoomListLambda = { roomList })
+ val presenter = createGlobalSearchPresenter(roomListService = roomListService)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized)
+
+ initialState.queryState.edit { append("A query") }
+ // Let the query be propagated to the data source before emitting results
+ testScheduler.advanceUntilIdle()
+ roomList.summaries.emit(listOf(aRoomSummary()))
+
+ // Wait for the presenter to emit a loading state before the success state
+ consumeItemsUntilPredicate { it.results is AsyncData.Loading }
+
+ val successState = awaitItem()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results
+ assertThat(results).hasSize(1)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - room search displays the uninitialized state when removing the query`() = runTest {
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(createRoomListLambda = { roomList })
+ val presenter = createGlobalSearchPresenter(roomListService = roomListService)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized)
+
+ initialState.queryState.edit { append("A query") }
+ // Let the query be propagated to the data source before emitting results
+ testScheduler.advanceUntilIdle()
+ roomList.summaries.emit(listOf(aRoomSummary()))
+
+ // Wait for the presenter to emit a loading state before the success state
+ consumeItemsUntilPredicate { it.results is AsyncData.Loading }
+
+ val successState = awaitItem()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results
+ assertThat(results).hasSize(1)
+
+ // Remove the query
+ successState.queryState.edit { replace(0, length, "") }
+ // Skip the intermediate state where the query is empty but results are still present
+ skipItems(1)
+ // It's back to the uninitialized state
+ assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java)
+ // This happens even if for some reason the room results still contained any items
+ assertThat(roomList.summaries.value).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun `present - message search results are mapped when the message search emits`() = runTest {
+ val messageSearch = FakeMessageSearch()
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
+ }
+ val presenter = createGlobalSearchPresenter(
+ messageSearchService = FakeMessageSearchService(messageSearch),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ initialState.queryState.edit { append("A query") }
+ testScheduler.advanceUntilIdle()
+
+ messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
+ testScheduler.advanceUntilIdle()
+
+ val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
+ assertThat(results).hasSize(1)
+ assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - message search displays the uninitialized state after removing the query`() = runTest {
+ val messageSearch = FakeMessageSearch()
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
+ }
+ val presenter = createGlobalSearchPresenter(
+ messageSearchService = FakeMessageSearchService(messageSearch),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ initialState.queryState.edit { append("A query") }
+ testScheduler.advanceUntilIdle()
+
+ messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
+ testScheduler.advanceUntilIdle()
+
+ val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
+ assertThat(results).hasSize(1)
+ assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
+
+ // Remove the query
+ successState.queryState.edit { replace(0, length, "") }
+ // Skip the intermediate state where the query is empty but results are still present
+ skipItems(1)
+ // It's back to the uninitialized state
+ assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java)
+ // This happens even if for some reason the message results still contained any items
+ assertThat(messageSearch.results.value).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
+ val messageSearch = FakeMessageSearch()
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
+ }
+ val presenter = createGlobalSearchPresenter(
+ messageSearchService = FakeMessageSearchService(messageSearch),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ initialState.queryState.edit { append("A query") }
+ testScheduler.advanceUntilIdle()
+
+ messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
+ val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
+
+ successState.eventSink(GlobalSearchEvent.UpdateVisibleRange(IntRange(0, 0)))
+ runCurrent()
+ testScheduler.advanceUntilIdle()
+
+ assertThat(messageSearch.paginateCallCount).isEqualTo(1)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
+
+private fun TestScope.createGlobalSearchPresenter(
+ roomListService: RoomListService = FakeRoomListService(),
+ messageSearchService: MessageSearchService = FakeMessageSearchService(),
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
+ latestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
+ dateFormatter: DateFormatter = FakeDateFormatter(),
+ fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
+ permalinkParser: PermalinkParser = FakePermalinkParser(),
+ matrixClient: MatrixClient = FakeMatrixClient(),
+): GlobalSearchPresenter {
+ return GlobalSearchPresenter(
+ roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
+ override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource {
+ return RoomListSearchDataSource(
+ coroutineScope = coroutineScope,
+ roomListService = roomListService,
+ coroutineDispatchers = testCoroutineDispatchers(),
+ roomSummaryFactory = aRoomListRoomSummaryFactory(),
+ )
+ }
+ },
+ messageSearchService = messageSearchService,
+ featureFlagService = featureFlagService,
+ latestEventFormatter = latestEventFormatter,
+ dateFormatter = dateFormatter,
+ fileSizeFormatter = fileSizeFormatter,
+ permalinkParser = permalinkParser,
+ coroutineDispatchers = testCoroutineDispatchers(),
+ matrixClient = matrixClient,
+ )
+}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt
index 4174d99222..3b9d512d80 100644
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt
@@ -10,13 +10,19 @@ package io.element.android.features.invite.api.declineandblock
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.architecture.FeatureEntryPoint
-fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
+interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
+ callback: Callback,
): Node
+
+ interface Callback : Plugin {
+ fun onDeclineSuccess()
+ }
}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt
index 2fe5c23088..89a2aaf238 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt
@@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@@ -31,6 +33,7 @@ class DeclineAndBlockNode(
data class Inputs(val inviteData: InviteData) : NodeInputs
private val inviteData = inputs().inviteData
+ private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inviteData)
@Composable
@@ -39,6 +42,7 @@ class DeclineAndBlockNode(
DeclineAndBlockView(
state = state,
onBackClick = ::navigateUp,
+ onDeclineSuccess = callback::onDeclineSuccess,
modifier = modifier
)
}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt
index 1d14df024a..841034516e 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt
@@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun DeclineAndBlockView(
state: DeclineAndBlockState,
onBackClick: () -> Unit,
+ onDeclineSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
@@ -53,7 +54,7 @@ fun DeclineAndBlockView(
val isDeclining = state.declineAction is AsyncAction.Loading
AsyncActionView(
async = state.declineAction,
- onSuccess = { onBackClick() },
+ onSuccess = { onDeclineSuccess() },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) },
onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) }
@@ -149,5 +150,6 @@ internal fun DeclineAndBlockViewPreview(
DeclineAndBlockView(
state = state,
onBackClick = {},
+ onDeclineSuccess = {},
)
}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt
index d6e93fb805..4a5734af45 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt
@@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
+ callback: DeclineInviteAndBlockEntryPoint.Callback,
): Node {
val inputs = DeclineAndBlockNode.Inputs(inviteData)
- return parentNode.createNode(buildContext, plugins = listOf(inputs))
+ return parentNode.createNode(buildContext, plugins = listOf(inputs, callback))
}
}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
index 55c31a09d2..5cfb091e82 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.invite.impl.R
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() {
}
}
+ @Test
+ fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ setDeclineAndBlockView(
+ aDeclineAndBlockState(
+ declineAction = AsyncAction.Success(Unit),
+ eventSink = eventsRecorder,
+ ),
+ onDeclineSuccess = it,
+ )
+ }
+ }
+
@Test
fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
@@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() {
private fun AndroidComposeUiTest.setDeclineAndBlockView(
state: DeclineAndBlockState,
onBackClick: () -> Unit = EnsureNeverCalled(),
+ onDeclineSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
DeclineAndBlockView(
state = state,
onBackClick = onBackClick,
+ onDeclineSuccess = onDeclineSuccess,
)
}
}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt
index 13bb13dccf..f09cda2337 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt
@@ -11,7 +11,9 @@ package io.element.android.features.invite.impl.declineandblock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.invite.test.anInviteData
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
@@ -31,12 +33,17 @@ class DefaultDeclineAndBlockEntryPointTest {
)
}
val inviteData = anInviteData()
+ val callback = object : DeclineInviteAndBlockEntryPoint.Callback {
+ override fun onDeclineSuccess() = lambdaError()
+ }
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
- inviteData = inviteData
+ inviteData = inviteData,
+ callback = callback,
)
assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java)
assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData))
+ assertThat(result.plugins).contains(callback)
}
}
diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt
index 52d4d17790..19625f1473 100644
--- a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt
+++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt
@@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
+ callback: DeclineInviteAndBlockEntryPoint.Callback,
): Node {
lambdaError()
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt
index 93f8f53412..493c180731 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt
@@ -69,6 +69,9 @@ class JoinRoomFlowNode(
parentNode = this,
buildContext = buildContext,
inviteData = navTarget.inviteData,
+ callback = object : DeclineInviteAndBlockEntryPoint.Callback {
+ override fun onDeclineSuccess() = navigateUp()
+ },
)
NavTarget.Root -> rootNode(buildContext)
}
@@ -99,7 +102,7 @@ class JoinRoomFlowNode(
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInviteSuccess = {},
- onDeclineInviteSuccess = {},
+ onDeclineInviteSuccess = { navigateUp() },
modifier = Modifier
)
}
diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts
index 86a8e0e7bc..6cf8945e05 100644
--- a/features/login/impl/build.gradle.kts
+++ b/features/login/impl/build.gradle.kts
@@ -69,6 +69,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
implementation(projects.libraries.oauth.api)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
@@ -80,6 +81,7 @@ dependencies {
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test)
+ testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
index a662f20351..bbfe7c47d8 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
@@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
-import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
@@ -33,13 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
-import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
-import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
@@ -129,12 +126,6 @@ class LoginFlowNode(
@Parcelize
data object ChooseAccountProvider : NavTarget
- @Parcelize
- data object ChangeAccountProvider : NavTarget
-
- @Parcelize
- data object SearchAccountProvider : NavTarget
-
@Parcelize
data class LoginPassword(
val initialLogin: String = "",
@@ -261,43 +252,9 @@ class LoginFlowNode(
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
-
- override fun navigateToChangeAccountProvider() {
- backstack.push(NavTarget.ChangeAccountProvider)
- }
}
createNode(buildContext, plugins = listOf(inputs, callback))
}
- NavTarget.ChangeAccountProvider -> {
- val callback = object : ChangeAccountProviderNode.Callback {
- override fun onDone() {
- // Go back to the Account Provider screen
- val confirmAccountProvider = backstack.elements.value.firstOrNull {
- it.key.navTarget is NavTarget.ConfirmAccountProvider
- }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
- backstack.singleTop(confirmAccountProvider)
- }
-
- override fun navigateToSearchAccountProvider() {
- backstack.push(NavTarget.SearchAccountProvider)
- }
- }
-
- createNode(buildContext, plugins = listOf(callback))
- }
- NavTarget.SearchAccountProvider -> {
- val callback = object : SearchAccountProviderNode.Callback {
- override fun onDone() {
- // Go back to the Account Provider screen
- val confirmAccountProvider = backstack.elements.value.firstOrNull {
- it.key.navTarget is NavTarget.ConfirmAccountProvider
- }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
- backstack.singleTop(confirmAccountProvider)
- }
- }
-
- createNode(buildContext, plugins = listOf(callback))
- }
is NavTarget.LoginPassword -> {
val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
index ad016dec26..6d85f2c5ff 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
@@ -13,27 +13,64 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
+import io.element.android.libraries.di.annotations.AppCoroutineScope
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
@SingleIn(AppScope::class)
@Inject
class AccountProviderDataSource(
- enterpriseService: EnterpriseService,
+ private val enterpriseService: EnterpriseService,
+ private val appPreferencesStore: AppPreferencesStore,
+ @AppCoroutineScope private val coroutineScope: CoroutineScope,
) {
- private val defaultAccountProvider = createAccountProvider(
+ // The provider used when the user has not selected one: an enterprise/MDM-configured provider,
+ // else matrix.org. The most recently used provider (from history) can override it, see init.
+ private val configuredAccountProvider = createAccountProvider(
url = enterpriseService.homeserverAllowList()
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
?: AuthenticationConfig.MATRIX_ORG_URL
)
- private val accountProvider: MutableStateFlow = MutableStateFlow(defaultAccountProvider)
+ private val accountProvider: MutableStateFlow = MutableStateFlow(configuredAccountProvider)
val flow: StateFlow = accountProvider.asStateFlow()
+ // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
+ // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
+ // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
+ var lastSelectedAccountProviderUrl: String? = null
+ private set
+
+ init {
+ // Seed the default from the last used provider, unless the user has already selected one.
+ coroutineScope.launch {
+ val default = defaultAccountProvider()
+ accountProvider.update { current -> if (current == configuredAccountProvider) default else current }
+ }
+ }
+
suspend fun reset() {
- accountProvider.emit(defaultAccountProvider)
+ accountProvider.emit(defaultAccountProvider())
+ }
+
+ /**
+ * The provider to default to: the most recently used one from history when the user is free to
+ * connect to any provider, otherwise the enterprise/MDM-configured provider.
+ */
+ private suspend fun defaultAccountProvider(): AccountProvider {
+ if (!enterpriseService.canConnectToAnyHomeserver()) {
+ return configuredAccountProvider
+ }
+ val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
+ return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider
}
suspend fun setUrl(url: String) {
@@ -41,6 +78,7 @@ class AccountProviderDataSource(
}
suspend fun setAccountProvider(data: AccountProvider) {
+ lastSelectedAccountProviderUrl = data.url
accountProvider.emit(data)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt
deleted file mode 100644
index 8b9b22022e..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.accountprovider
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.login.impl.R
-import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
-import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
-import io.element.android.libraries.designsystem.theme.components.Text
-
-/**
- * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
- */
-@Composable
-fun AccountProviderOtherView(
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Column(
- modifier = modifier
- .fillMaxWidth()
- .clickable(role = Role.Button) { onClick() }
- ) {
- HorizontalDivider()
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = 44.dp)
- .padding(vertical = 4.dp, horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- RoundedIconAtom(
- size = RoundedIconAtomSize.Medium,
- imageVector = CompoundIcons.Search(),
- tint = ElementTheme.colors.iconPrimary,
- )
- Text(
- modifier = Modifier
- .padding(start = 16.dp)
- .weight(1f),
- text = stringResource(R.string.screen_change_account_provider_other),
- style = ElementTheme.typography.fontBodyLgMedium,
- color = ElementTheme.colors.textPrimary,
- )
- }
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun AccountProviderOtherViewPreview() = ElementPreview {
- AccountProviderOtherView(
- onClick = { },
- )
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt
new file mode 100644
index 0000000000..b3b99115cc
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+
+/**
+ * Persist the account provider the user just authenticated against, so that it can be offered as
+ * the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next
+ * sign-in. To be called from the manual sign-in / account creation flows only, on success.
+ *
+ * See [AppPreferencesStore.addHomeserverToHistory].
+ */
+@Inject
+class SaveAccountProviderToHistory(
+ private val accountProviderDataSource: AccountProviderDataSource,
+ private val appPreferencesStore: AppPreferencesStore,
+) {
+ suspend operator fun invoke() {
+ // Persist the provider the user actually selected, not the currently-exposed default: the latter can be
+ // reset back to the history default while the login flow is torn down across an OAuth round-trip.
+ val url = accountProviderDataSource.lastSelectedAccountProviderUrl
+ ?: accountProviderDataSource.flow.value.url
+ appPreferencesStore.addHomeserverToHistory(url)
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
index 79f76d5fb5..b7b04beeb2 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
@@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
@Inject
class ChangeServerPresenter(
@@ -32,7 +33,7 @@ class ChangeServerPresenter(
) : Presenter {
@Composable
override fun present(): ChangeServerState {
- val changeServerAction: MutableState> = remember {
+ val changeServerAction: MutableState> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
@@ -59,7 +60,7 @@ class ChangeServerPresenter(
private suspend fun changeServer(
data: AccountProvider,
- changeServerAction: MutableState>,
+ changeServerAction: MutableState>,
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
@@ -72,6 +73,8 @@ class ChangeServerPresenter(
}
// Homeserver is valid, remember user choice
accountProviderDataSource.setAccountProvider(data)
+ // Return the resolved details so the caller can sign in without configuring the homeserver again.
+ details
}.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt
index 6d8ce25d75..3b4f4d9dad 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt
@@ -9,10 +9,13 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class ChangeServerState(
- val changeServerAction: AsyncData,
+ // On success, carries the resolved homeserver details so the caller can proceed with login without
+ // configuring (and re-networking) the homeserver a second time.
+ val changeServerAction: AsyncData,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt
index 183158bceb..dd1102e25e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
open class ChangeServerStateProvider : PreviewParameterProvider {
@@ -45,7 +46,7 @@ open class ChangeServerStateProvider : PreviewParameterProvider = AsyncData.Uninitialized,
+ changeServerAction: AsyncData = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState(
changeServerAction = changeServerAction,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt
index 73400890d5..945b0bc580 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt
@@ -7,12 +7,18 @@
package io.element.android.features.login.impl.login
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
+
sealed interface LoginModeEvent {
data class Submit(
val isAccountCreation: Boolean,
val homeserverUrl: String,
val resolvedHomeserverUrl: String?,
val loginHint: String?,
+ // When the caller has already configured the homeserver (e.g. the account provider screen validated
+ // it first), these are its resolved details. When set, login reuses them instead of calling
+ // setHomeserver again, avoiding a redundant network round-trip.
+ val preConfiguredDetails: MatrixHomeServerDetails? = null,
) : LoginModeEvent
data object ClearError : LoginModeEvent
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt
index 466cd210ab..59d156308f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
@@ -39,6 +40,7 @@ class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
+ private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter {
@Composable
override fun present(): LoginModeState {
@@ -73,26 +75,28 @@ class LoginModePresenter(
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState>) {
suspend {
- authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
- // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
- if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
- authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
- } else {
- throw it
- }
- }.map { matrixHomeServerDetails ->
- when {
- matrixHomeServerDetails.supportsOAuthLogin -> {
- val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
- LoginMode.OAuth(
- authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
- )
+ // Reuse the details the caller already resolved when it configured the homeserver, so we don't
+ // run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves.
+ val matrixHomeServerDetails = request.preConfiguredDetails
+ ?: authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
+ // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
+ if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
+ authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
+ } else {
+ throw it
}
- request.isAccountCreation -> throw AccountCreationNotSupported()
- matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
- else -> error("Unsupported authentication flow")
+ }.getOrThrow()
+ when {
+ matrixHomeServerDetails.supportsOAuthLogin -> {
+ val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
+ LoginMode.OAuth(
+ authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
+ )
}
- }.getOrThrow()
+ request.isAccountCreation -> throw AccountCreationNotSupported()
+ matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
+ else -> error("Unsupported authentication flow")
+ }
}.runCatchingUpdatingState(
state = loginMode,
errorTransform = {
@@ -116,6 +120,7 @@ class LoginModePresenter(
.onSuccess { loginMode.value = AsyncData.Uninitialized }
.onFailure { loginMode.value = AsyncData.Failure(it) }
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
+ .onSuccess { saveAccountProviderToHistory() }
.onFailure { loginMode.value = AsyncData.Failure(it) }
}
oAuthActionFlow.reset()
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt
deleted file mode 100644
index d9994deb9a..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.resolver
-
-data class HomeserverData(
- // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
- val homeserverUrl: String,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt
deleted file mode 100644
index 2700922678..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.resolver
-
-import dev.zacsweers.metro.Inject
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.core.coroutine.parallelMap
-import io.element.android.libraries.core.uri.ensureProtocol
-import io.element.android.libraries.core.uri.isValidUrl
-import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
-import io.element.android.libraries.matrix.api.core.MatrixPatterns
-import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import java.util.Collections
-
-/**
- * Resolve homeserver base on search terms.
- */
-@Inject
-class HomeserverResolver(
- private val dispatchers: CoroutineDispatchers,
- private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
- private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
-) {
- fun resolve(userInput: String): Flow> = flow {
- val flowContext = currentCoroutineContext()
- val trimmedUserInput = userInput.trim()
- val searchTerm = when {
- MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
- trimmedUserInput.startsWith("@") -> return@flow
- else -> trimmedUserInput
- }
- if (searchTerm.length < 4) return@flow
- val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
- val list = getUrlCandidates(candidateBase)
- val currentList = Collections.synchronizedList(mutableListOf())
- // Run all the requests in parallel
- withContext(dispatchers.io) {
- list.parallelMap { url ->
- // Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
- // otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
- // candidate directly; the actual sign-in flow triggers the permission prompt.
- val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
- homeServerLoginCompatibilityChecker.check(url)
- .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
- .getOrNull()
- ?: return@parallelMap
-
- // Emit the list as soon as possible
- if (shouldRequestPermissionOrIsValid) {
- currentList.add(HomeserverData(homeserverUrl = url))
- withContext(flowContext) {
- emit(currentList.toList())
- }
- }
- }
- }
- // If list is empty, and candidateBase is a valid an URL, do not block the user.
- // A unsupported homeserver / homeserver not found error will be displayed if the user continues
- if (currentList.isEmpty() && candidateBase.isValidUrl()) {
- emit(listOf(HomeserverData(homeserverUrl = candidateBase)))
- }
- }
-
- private fun getUrlCandidates(data: String): List {
- return buildList {
- if (data.contains(".")) {
- // TLD detected?
- } else {
- add("$data.org")
- add("$data.com")
- add("$data.io")
- }
- // Always try what the user has entered
- add(data)
- }
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
deleted file mode 100644
index 018a25dc89..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.Assisted
-import dev.zacsweers.metro.AssistedInject
-import io.element.android.annotations.ContributesNode
-import io.element.android.features.login.impl.util.openLearnMorePage
-import io.element.android.libraries.architecture.callback
-
-@ContributesNode(AppScope::class)
-@AssistedInject
-class ChangeAccountProviderNode(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- private val presenter: ChangeAccountProviderPresenter,
-) : Node(buildContext, plugins = plugins) {
- interface Callback : Plugin {
- fun onDone()
- fun navigateToSearchAccountProvider()
- }
-
- private val callback: Callback = callback()
-
- @Composable
- override fun View(modifier: Modifier) {
- val state = presenter.present()
- val context = LocalContext.current
- ChangeAccountProviderView(
- state = state,
- modifier = modifier,
- onBackClick = ::navigateUp,
- onLearnMoreClick = { openLearnMorePage(context) },
- onSuccess = callback::onDone,
- onOtherProviderClick = callback::navigateToSearchAccountProvider,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
deleted file mode 100644
index 826cca0c2b..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import dev.zacsweers.metro.Inject
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.enterprise.api.EnterpriseService
-import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.uri.ensureProtocol
-import kotlinx.collections.immutable.toImmutableList
-
-@Inject
-class ChangeAccountProviderPresenter(
- private val changeServerPresenter: Presenter,
- private val enterpriseService: EnterpriseService,
-) : Presenter {
- @Composable
- override fun present(): ChangeAccountProviderState {
- val staticAccountProviderList = remember {
- enterpriseService.homeserverAllowList()
- .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
- .map { it.ensureProtocol() }
- .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
- .map { url ->
- AccountProvider(
- url = url,
- subtitle = null,
- isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
- isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
- )
- }
- .toImmutableList()
- }
-
- val canSearchForAccountProviders = remember {
- enterpriseService.canConnectToAnyHomeserver()
- }
-
- val changeServerState = changeServerPresenter.present()
- return ChangeAccountProviderState(
- accountProviders = staticAccountProviderList,
- canSearchForAccountProviders = canSearchForAccountProviders,
- changeServerState = changeServerState,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt
deleted file mode 100644
index 3068a1cb57..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import kotlinx.collections.immutable.ImmutableList
-
-data class ChangeAccountProviderState(
- val accountProviders: ImmutableList,
- val canSearchForAccountProviders: Boolean,
- val changeServerState: ChangeServerState,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt
deleted file mode 100644
index 5cec1961a4..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.accountprovider.anAccountProvider
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import kotlinx.collections.immutable.toImmutableList
-
-open class ChangeAccountProviderStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aChangeAccountProviderState(),
- aChangeAccountProviderState(canSearchForAccountProviders = false),
- // Add other state here
- )
-}
-
-fun aChangeAccountProviderState(
- accountProviders: List = listOf(
- anAccountProvider()
- ),
- canSearchForAccountProviders: Boolean = true,
- changeServerState: ChangeServerState = aChangeServerState(),
-) = ChangeAccountProviderState(
- accountProviders = accountProviders.toImmutableList(),
- canSearchForAccountProviders = canSearchForAccountProviders,
- changeServerState = changeServerState,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt
deleted file mode 100644
index 212bab515d..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-@file:OptIn(ExperimentalMaterial3Api::class)
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.consumeWindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.login.impl.R
-import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView
-import io.element.android.features.login.impl.accountprovider.AccountProviderView
-import io.element.android.features.login.impl.changeserver.ChangeServerEvents
-import io.element.android.features.login.impl.changeserver.ChangeServerView
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
-import io.element.android.libraries.designsystem.components.BigIcon
-import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.Scaffold
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
-import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
-
-/**
- * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
- */
-@Composable
-fun ChangeAccountProviderView(
- state: ChangeAccountProviderState,
- onBackClick: () -> Unit,
- onLearnMoreClick: () -> Unit,
- onSuccess: () -> Unit,
- onOtherProviderClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- title = {},
- navigationIcon = { BackButton(onClick = onBackClick) }
- )
- },
- contentWindowInsets = scaffoldScrollableContentInsets,
- ) { padding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .imePadding()
- .padding(padding)
- .consumeWindowInsets(padding)
- ) {
- Column(
- modifier = Modifier
- .verticalScroll(state = rememberScrollState())
- .padding(lazyColumnContentPadding)
- ) {
- IconTitleSubtitleMolecule(
- modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
- iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
- title = stringResource(id = R.string.screen_change_account_provider_title),
- subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
- )
-
- state.accountProviders.forEach { item ->
- val alteredItem = if (item.isMatrixOrg) {
- // Set the subtitle from the resource
- item.copy(
- subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
- )
- } else {
- item
- }
- AccountProviderView(
- item = alteredItem,
- onClick = {
- state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem))
- }
- )
- }
- // Other
- if (state.canSearchForAccountProviders) {
- AccountProviderOtherView(
- onClick = onOtherProviderClick
- )
- }
- Spacer(Modifier.height(32.dp))
- }
- ChangeServerView(
- state = state.changeServerState,
- onLearnMoreClick = onLearnMoreClick,
- onSuccess = onSuccess,
- )
- }
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview {
- ChangeAccountProviderView(
- state = state,
- onBackClick = { },
- onLearnMoreClick = { },
- onSuccess = { },
- onOtherProviderClick = { },
- )
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
index d66606b22c..9093a8121f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
@@ -9,6 +9,10 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
sealed interface ConfirmAccountProviderEvents {
- data object Continue : ConfirmAccountProviderEvents
+ data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents
+
+ // Carries the account provider to submit (the current field text, or the accepted autocomplete suggestion),
+ // captured from the view at click time so it can't lag behind fast input (e.g. keyboard autofill).
+ data class Continue(val accountProvider: String) : ConfirmAccountProviderEvents
data object ClearError : ConfirmAccountProviderEvents
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
index 0accaea767..e1a2d85412 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
@@ -45,7 +45,6 @@ class ConfirmAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
- fun navigateToChangeAccountProvider()
}
private val callback: Callback = callback()
@@ -59,7 +58,6 @@ class ConfirmAccountProviderNode(
modifier = modifier,
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
- onChange = callback::navigateToChangeAccountProvider,
onLearnMoreClick = { openLearnMorePage(context) },
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
index 0111b1e7d7..5c66f8f446 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
@@ -9,21 +9,38 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
+import io.element.android.appconfig.AuthenticationConfig
+import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.changeserver.ChangeServerEvents
+import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.uri.ensureProtocol
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@AssistedInject
class ConfirmAccountProviderPresenter(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginModePresenter: Presenter,
+ private val changeServerPresenter: Presenter,
+ private val appPreferencesStore: AppPreferencesStore,
+ private val enterpriseService: EnterpriseService,
) : Presenter {
data class Params(
val isAccountCreation: Boolean,
@@ -38,26 +55,108 @@ class ConfirmAccountProviderPresenter(
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.present()
+ val changeServerState = changeServerPresenter.present()
+ val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList())
+
+ // The account providers offered for autocomplete: previously-used ones first, then the
+ // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in).
+ // The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is
+ // rendered without the https:// scheme (added back at connection time).
+ val autocompleteCandidates = remember(homeserverHistory) {
+ (homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL)
+ .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
+ .map { it.withoutScheme() }
+ .distinct()
+ }
+
+ // Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
+ // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org".
+ var userInput by rememberSaveable { mutableStateOf(null) }
+ val accountProviderInput = userInput ?: accountProvider.url.withoutScheme()
+
+ // Offer the first candidate that the current input is a (case-insensitive) prefix of.
+ val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) {
+ val input = accountProviderInput.trim()
+ input.takeIf { it.isNotEmpty() }
+ ?.let { prefix ->
+ autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) }
+ }
+ }
+
+ // The account provider submitted via Continue (carried by the event, so it reflects the exact field
+ // text at click time); used to actually sign in once validation succeeds.
+ var submittedAccountProvider by remember { mutableStateOf(null) }
+ val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider)
+
+ // A login hint (the full Matrix user ID) captured when the user enters one, so the authentication
+ // server can pre-fill their identity. Null when a plain account provider was entered.
+ var submittedLoginHint by remember { mutableStateOf(null) }
+ val latestSubmittedLoginHint by rememberUpdatedState(submittedLoginHint)
+
+ // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
+ // Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again.
+ LaunchedEffect(changeServerState.changeServerAction) {
+ val homeServerDetails = changeServerState.changeServerAction.dataOrNull()
+ if (homeServerDetails != null) {
+ loginModeState.eventSink(
+ LoginModeEvent.Submit(
+ isAccountCreation = params.isAccountCreation,
+ homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(),
+ resolvedHomeserverUrl = null,
+ loginHint = latestSubmittedLoginHint,
+ preConfiguredDetails = homeServerDetails,
+ )
+ )
+ }
+ }
fun handleEvent(event: ConfirmAccountProviderEvents) {
when (event) {
- ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink(
- LoginModeEvent.Submit(
- isAccountCreation = params.isAccountCreation,
- homeserverUrl = accountProvider.url,
- resolvedHomeserverUrl = null,
- loginHint = null,
+ is ConfirmAccountProviderEvents.UserInputChanged -> {
+ userInput = event.accountProvider
+ }
+ // Validate (and persist) the chosen account provider before proceeding. This also enforces the
+ // account-provider access control, which the login submit does not run on its own.
+ is ConfirmAccountProviderEvents.Continue -> {
+ // Apply the accepted account provider (any accepted completion) back into the field so it
+ // renders the full server rather than the typed prefix, and survives the OAuth round-trip.
+ val accountProvider = event.accountProvider.trim()
+ userInput = accountProvider
+ // If the user entered a full Matrix user ID (e.g. "@alice:example.org"), sign in to its
+ // homeserver and pass the ID as a login hint so the authentication server can pre-fill it.
+ // Otherwise treat the input as the account provider host. Only the homeserver is ever
+ // persisted to history, never the user ID.
+ val isUserId = MatrixPatterns.isUserId(accountProvider)
+ // For a user ID the homeserver is the domain part (after the ':'), keeping any port.
+ val host = if (isUserId) accountProvider.substringAfter(":") else accountProvider
+ submittedLoginHint = "mxid:$accountProvider".takeIf { isUserId && !params.isAccountCreation }
+ // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works.
+ val accountProviderUrl = host.ensureProtocol()
+ submittedAccountProvider = accountProviderUrl
+ changeServerState.eventSink(
+ ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl))
)
- )
- ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
+ }
+ ConfirmAccountProviderEvents.ClearError -> {
+ loginModeState.eventSink(LoginModeEvent.ClearError)
+ changeServerState.eventSink(ChangeServerEvents.ClearError)
+ }
}
}
return ConfirmAccountProviderState(
- accountProvider = accountProvider,
+ accountProviderInput = accountProviderInput,
+ accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = params.isAccountCreation,
loginModeState = loginModeState,
+ changeServerState = changeServerState,
eventSink = ::handleEvent,
)
}
}
+
+/**
+ * The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed.
+ * Returns the input unchanged when it has no scheme (e.g. `matrix.org`).
+ */
+private fun String.withoutScheme(): String = substringAfter("://")
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
index 6e6deb8c2c..bf6b0cb0e7 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
@@ -8,17 +8,29 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
-import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
data class ConfirmAccountProviderState(
- val accountProvider: AccountProvider,
+ val accountProviderInput: String,
+ // The full account provider from history that the current input is a prefix of, offered as inline autocomplete.
+ val accountProviderSuggestion: String?,
val isAccountCreation: Boolean,
val loginModeState: LoginModeState,
+ val changeServerState: ChangeServerState,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean
- get() = accountProvider.url.isNotEmpty() &&
- (loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
+ get() = accountProviderInput.isNotBlank() &&
+ loginModeState.loginMode is AsyncData.Uninitialized &&
+ changeServerState.changeServerAction is AsyncData.Uninitialized
+
+ val isLoading: Boolean
+ get() = loginModeState.loginMode is AsyncData.Loading ||
+ changeServerState.changeServerAction is AsyncData.Loading
+
+ val isShowingError: Boolean
+ get() = loginModeState.loginMode is AsyncData.Failure ||
+ changeServerState.changeServerAction is AsyncData.Failure
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
index f597ad6b54..53fa763c2f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
@@ -9,8 +9,8 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.accountprovider.anAccountProvider
+import io.element.android.features.login.impl.changeserver.ChangeServerState
+import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
@@ -20,6 +20,10 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider
get() = sequenceOf(
aConfirmAccountProviderState(),
+ aConfirmAccountProviderState(
+ accountProviderInput = "element",
+ accountProviderSuggestion = "element.io",
+ ),
aConfirmAccountProviderState(
isAccountCreation = true,
),
@@ -31,13 +35,17 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider Unit = {},
) = ConfirmAccountProviderState(
- accountProvider = accountProvider,
+ accountProviderInput = accountProviderInput,
+ accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = isAccountCreation,
loginModeState = loginModeState,
+ changeServerState = changeServerState,
eventSink = eventSink
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
index 3702b195a1..e206b624cf 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
@@ -8,50 +8,110 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
+import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
-import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
-import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
+/**
+ * Account provider entry screen, shared by sign in and account creation.
+ * Figma (Compound Android Templates):
+ * - Sign in: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10251
+ * - Create account: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10239
+ */
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
- onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
- val isLoading by remember(state.loginModeState.loginMode) {
- derivedStateOf {
- state.loginModeState.loginMode is AsyncData.Loading
- }
- }
val eventSink = state.eventSink
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ var input by textFieldState(stateValue = state.accountProviderInput)
+
+ // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and
+ // accepted on Continue / Enter). Only valid while the suggestion still starts with the current input.
+ val suggestionSuffix = state.accountProviderSuggestion
+ ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) }
+ ?.substring(input.length)
+ .orEmpty()
+
+ // The Continue button lives in a footer slot that is not recomposed while the user types, so a direct
+ // capture of the field text (via ::submit) goes stale and can submit an empty/prefilled value. Reading
+ // the value through rememberUpdatedState keeps it current regardless of which composition scope reads it.
+ val accountProviderToSubmit by rememberUpdatedState(input + suggestionSuffix)
+
+ fun submit() {
+ // Dismiss the keyboard and release focus while the account provider is being validated.
+ focusManager.clearFocus(force = true)
+ // Submit the exact field text (plus any accepted suggestion).
+ eventSink(ConfirmAccountProviderEvents.Continue(accountProviderToSubmit))
+ }
+
+ // Once a validation / login error has been dismissed, return focus and the keyboard to the field so
+ // the user can correct the account provider and retry.
+ var wasShowingError by remember { mutableStateOf(false) }
+ LaunchedEffect(state.isShowingError) {
+ if (wasShowingError && !state.isShowingError) {
+ focusRequester.requestFocus()
+ keyboardController?.show()
+ }
+ wasShowingError = state.isShowingError
+ }
HeaderFooterPage(
modifier = modifier,
@@ -61,54 +121,113 @@ fun ConfirmAccountProviderView(
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource(
id = if (state.isAccountCreation) {
- R.string.screen_account_provider_signup_title
+ R.string.screen_change_server_title_register
} else {
- R.string.screen_account_provider_signin_title
- },
- state.accountProvider.title
+ R.string.screen_change_server_title_login
+ }
),
- subTitle = stringResource(
- id = if (state.isAccountCreation) {
- R.string.screen_account_provider_signup_subtitle
- } else {
- R.string.screen_account_provider_signin_subtitle
- },
- )
+ subTitle = null,
)
},
footer = {
ButtonColumnMolecule {
Button(
text = stringResource(id = CommonStrings.action_continue),
- showProgress = isLoading,
- onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
- enabled = state.submitEnabled || isLoading,
+ showProgress = state.isLoading,
+ onClick = ::submit,
+ enabled = state.submitEnabled && !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
- TextButton(
- text = stringResource(id = R.string.screen_account_provider_change),
- onClick = onChange,
- enabled = !isLoading,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(TestTags.loginChangeServer)
- )
}
}
) {
- LoginModeView(
- loginMode = state.loginModeState.loginMode,
- onClearError = {
- eventSink(ConfirmAccountProviderEvents.ClearError)
+ val ghostColor = ElementTheme.colors.textSecondary
+ val ghostTransformation = remember(suggestionSuffix, ghostColor) {
+ GhostSuffixVisualTransformation(suggestionSuffix, ghostColor)
+ }
+ TextField(
+ value = input,
+ onValueChange = {
+ input = it
+ eventSink(ConfirmAccountProviderEvents.UserInputChanged(it))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 40.dp)
+ .focusRequester(focusRequester)
+ .testTag(TestTags.changeServerServer),
+ label = stringResource(id = R.string.screen_change_server_textfield_header),
+ placeholder = stringResource(id = R.string.screen_change_server_textfield_placeholder),
+ supportingText = stringResource(
+ id = if (state.isAccountCreation) {
+ R.string.screen_change_server_textfield_footer_register
+ } else {
+ R.string.screen_change_server_textfield_footer_login
+ }
+ ),
+ visualTransformation = ghostTransformation,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (suggestionSuffix.isNotEmpty()) {
+ // A completion is showing: accept it into the field and drop focus, dismissing the
+ // keyboard. This shows the completed server (with no stray mid-text cursor, since the
+ // field is no longer focused) ready to sign in with Continue. When no completion is
+ // showing, Done submits directly.
+ val accepted = input + suggestionSuffix
+ input = accepted
+ eventSink(ConfirmAccountProviderEvents.UserInputChanged(accepted))
+ focusManager.clearFocus(force = true)
+ } else {
+ submit()
+ }
+ }
+ ),
+ singleLine = true,
+ trailingIcon = if (input.isNotEmpty()) {
+ {
+ Box(
+ Modifier.clickable(
+ onClickLabel = stringResource(CommonStrings.action_clear),
+ role = Role.Button,
+ ) {
+ input = ""
+ eventSink(ConfirmAccountProviderEvents.UserInputChanged(""))
+ }
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Close(),
+ contentDescription = stringResource(CommonStrings.action_clear)
+ )
+ }
+ }
+ } else {
+ null
},
- onLearnMoreClick = onLearnMoreClick,
- onOAuthDetails = onOAuthDetails,
- onNeedLoginPassword = onNeedLoginPassword,
)
}
+ // Renders the account-provider validation errors, progress and permission dialogs.
+ // The successful validation itself is observed by the presenter, which then proceeds with the login flow.
+ ChangeServerView(
+ state = state.changeServerState,
+ onLearnMoreClick = onLearnMoreClick,
+ onSuccess = {},
+ )
+
+ LoginModeView(
+ loginMode = state.loginModeState.loginMode,
+ onClearError = { eventSink(ConfirmAccountProviderEvents.ClearError) },
+ onLearnMoreClick = onLearnMoreClick,
+ onOAuthDetails = onOAuthDetails,
+ onNeedLoginPassword = onNeedLoginPassword,
+ )
+
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
@@ -120,6 +239,29 @@ fun ConfirmAccountProviderView(
)
}
+/**
+ * Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint.
+ * The underlying field value stays limited to what the user actually typed, so the cursor can never
+ * enter the suggested region.
+ */
+private class GhostSuffixVisualTransformation(
+ private val suffix: String,
+ private val color: Color,
+) : VisualTransformation {
+ override fun filter(text: AnnotatedString): TransformedText {
+ if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity)
+ val transformed = buildAnnotatedString {
+ append(text)
+ withStyle(SpanStyle(color = color)) { append(suffix) }
+ }
+ val offsetMapping = object : OffsetMapping {
+ override fun originalToTransformed(offset: Int) = offset
+ override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length)
+ }
+ return TransformedText(transformed, offsetMapping)
+ }
+}
+
@PreviewsDayNight
@Composable
internal fun ConfirmAccountProviderViewPreview(
@@ -130,6 +272,5 @@ internal fun ConfirmAccountProviderViewPreview(
onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
- onChange = {},
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
index f26f342a42..6e28bd8781 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
@@ -20,6 +20,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -33,6 +34,7 @@ class LoginPasswordPresenter(
private val initialLogin: String,
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
+ private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -83,6 +85,7 @@ class LoginPasswordPresenter(
loggedInState.value = AsyncData.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
+ saveAccountProviderToHistory()
loggedInState.value = AsyncData.Success(sessionId)
}
.onFailure { failure ->
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
index 850e4332dd..e9bdb5e1e9 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
@@ -60,7 +60,7 @@ class OnBoardingPresenter(
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val forcedAccountProvider = remember {
- // If defaultHomeserverList() returns a singleton list, this is the default account provider.
+ // If homeserverAllowList() returns a singleton list, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
enterpriseService.homeserverAllowList().singleOrNull()
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt
deleted file mode 100644
index 8816de3946..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-sealed interface SearchAccountProviderEvents {
- /**
- * The user has typed something, expect to get a list of matching account provider results
- * in the state.
- */
- data class UserInput(val input: String) : SearchAccountProviderEvents
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
deleted file mode 100644
index ddbcc4c847..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.Assisted
-import dev.zacsweers.metro.AssistedInject
-import io.element.android.annotations.ContributesNode
-import io.element.android.features.login.impl.util.openLearnMorePage
-import io.element.android.libraries.architecture.callback
-
-@ContributesNode(AppScope::class)
-@AssistedInject
-class SearchAccountProviderNode(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- private val presenter: SearchAccountProviderPresenter,
-) : Node(buildContext, plugins = plugins) {
- interface Callback : Plugin {
- fun onDone()
- }
-
- private val callback: Callback = callback()
-
- @Composable
- override fun View(modifier: Modifier) {
- val state = presenter.present()
- val context = LocalContext.current
- SearchAccountProviderView(
- state = state,
- modifier = modifier,
- onBackClick = ::navigateUp,
- onLearnMoreClick = { openLearnMorePage(context) },
- onSuccess = callback::onDone,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt
deleted file mode 100644
index 2efe4501c6..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import dev.zacsweers.metro.Inject
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.features.login.impl.resolver.HomeserverResolver
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.architecture.Presenter
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-
-@Inject
-class SearchAccountProviderPresenter(
- private val homeserverResolver: HomeserverResolver,
- private val changeServerPresenter: Presenter,
-) : Presenter {
- @Composable
- override fun present(): SearchAccountProviderState {
- var userInput by rememberSaveable {
- mutableStateOf("")
- }
- val changeServerState = changeServerPresenter.present()
-
- val data: MutableState>> = remember {
- mutableStateOf(AsyncData.Uninitialized)
- }
-
- LaunchedEffect(userInput) {
- onUserInput(userInput, data)
- }
-
- fun handleEvent(event: SearchAccountProviderEvents) {
- when (event) {
- is SearchAccountProviderEvents.UserInput -> {
- userInput = event.input
- }
- }
- }
-
- return SearchAccountProviderState(
- userInput = userInput,
- userInputResult = data.value,
- changeServerState = changeServerState,
- eventSink = ::handleEvent,
- )
- }
-
- private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch {
- data.value = AsyncData.Uninitialized
- // Debounce
- delay(500)
- data.value = AsyncData.Loading()
- homeserverResolver.resolve(userInput).collect {
- data.value = AsyncData.Success(it)
- }
- if (data.value !is AsyncData.Success) {
- data.value = AsyncData.Uninitialized
- }
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt
deleted file mode 100644
index dc02a997a6..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.libraries.architecture.AsyncData
-
-data class SearchAccountProviderState(
- val userInput: String,
- val userInputResult: AsyncData>,
- val changeServerState: ChangeServerState,
- val eventSink: (SearchAccountProviderEvents) -> Unit
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
deleted file mode 100644
index 2a8512c38a..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.libraries.architecture.AsyncData
-
-open class SearchAccountProviderStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aSearchAccountProviderState(),
- aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())),
- // Add other state here
- )
-}
-
-fun aSearchAccountProviderState(
- userInput: String = "",
- userInputResult: AsyncData> = AsyncData.Uninitialized,
-) = SearchAccountProviderState(
- userInput = userInput,
- userInputResult = userInputResult,
- changeServerState = aChangeServerState(),
- eventSink = {}
-)
-
-fun aHomeserverDataList(): List {
- return listOf(
- aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL),
- aHomeserverData(homeserverUrl = "https://no.sliding.sync"),
- aHomeserverData(homeserverUrl = "https://invalid"),
- )
-}
-
-fun aHomeserverData(
- homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
-): HomeserverData {
- return HomeserverData(homeserverUrl = homeserverUrl)
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
deleted file mode 100644
index ffe7192991..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
+++ /dev/null
@@ -1,216 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-@file:OptIn(ExperimentalMaterial3Api::class)
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.consumeWindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusDirection
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.login.impl.R
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.accountprovider.AccountProviderView
-import io.element.android.features.login.impl.changeserver.ChangeServerEvents
-import io.element.android.features.login.impl.changeserver.ChangeServerView
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
-import io.element.android.libraries.designsystem.components.BigIcon
-import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.components.form.textFieldState
-import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible
-import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
-import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.TextField
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.testtags.TestTags
-import io.element.android.libraries.testtags.testTag
-import io.element.android.libraries.ui.strings.CommonStrings
-
-/**
- * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
- */
-@Composable
-fun SearchAccountProviderView(
- state: SearchAccountProviderState,
- onBackClick: () -> Unit,
- onLearnMoreClick: () -> Unit,
- onSuccess: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val eventSink = state.eventSink
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- title = {},
- navigationIcon = { BackButton(onClick = onBackClick) }
- )
- }
- ) { padding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .imePadding()
- .padding(padding)
- .consumeWindowInsets(padding)
- ) {
- LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
- item {
- IconTitleSubtitleMolecule(
- modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
- iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
- title = stringResource(id = R.string.screen_account_provider_form_title),
- subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
- )
- }
- item {
- // TextInput
- var userInputState by textFieldState(stateValue = state.userInput)
- val focusManager = LocalFocusManager.current
- TextField(
- value = userInputState,
- // readOnly = isLoading,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
- .bringIntoViewOnImeVisible()
- .onTabOrEnterKeyFocusNext(focusManager)
- .testTag(TestTags.changeServerServer),
- onValueChange = {
- userInputState = it
- eventSink(SearchAccountProviderEvents.UserInput(it))
- },
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Uri,
- imeAction = ImeAction.Done,
- ),
- keyboardActions = KeyboardActions(onDone = {
- focusManager.moveFocus(FocusDirection.Down)
- }),
- singleLine = true,
- trailingIcon = if (userInputState.isNotEmpty()) {
- {
- Box(
- Modifier.clickable(
- onClickLabel = stringResource(CommonStrings.action_clear),
- role = Role.Button,
- ) {
- userInputState = ""
- eventSink(SearchAccountProviderEvents.UserInput(""))
- }
- ) {
- Icon(
- imageVector = CompoundIcons.Close(),
- contentDescription = stringResource(CommonStrings.action_clear)
- )
- }
- }
- } else {
- null
- },
- supportingText = stringResource(id = R.string.screen_account_provider_form_notice),
- )
- }
-
- when (state.userInputResult) {
- is AsyncData.Failure -> {
- // Ignore errors (let the user type more chars)
- }
- is AsyncData.Loading -> {
- item {
- Box(
- modifier = Modifier
- .fillMaxSize()
- ) {
- CircularProgressIndicator(
- modifier = Modifier.align(Alignment.Center)
- )
- }
- }
- }
- is AsyncData.Success -> {
- items(state.userInputResult.data) { homeserverData ->
- val item = homeserverData.toAccountProvider()
- AccountProviderView(
- item = item,
- onClick = {
- state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item))
- }
- )
- }
- }
- AsyncData.Uninitialized -> Unit
- }
- item {
- Spacer(Modifier.height(32.dp))
- }
- }
- ChangeServerView(
- state = state.changeServerState,
- onLearnMoreClick = onLearnMoreClick,
- onSuccess = onSuccess,
- )
- }
- }
-}
-
-@Composable
-private fun HomeserverData.toAccountProvider(): AccountProvider {
- val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
- return AccountProvider(
- url = homeserverUrl,
- subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
- // There is no need to know for other servers right now
- isPublic = isMatrixOrg,
- isMatrixOrg = isMatrixOrg,
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview {
- SearchAccountProviderView(
- state = state,
- onBackClick = {},
- onLearnMoreClick = {},
- onSuccess = {},
- )
-}
diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml
index 33b3741234..13a93164a6 100644
--- a/features/login/impl/src/main/res/values-es/translations.xml
+++ b/features/login/impl/src/main/res/values-es/translations.xml
@@ -25,8 +25,16 @@
"No se permite el proveedor de cuentas %1$s."
"URL del servidor base"
"Introduce una dirección de dominio."
+ "Iniciar sesión"
+ "Crear cuenta"
"¿Cuál es la dirección de tu servidor?"
+ "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)."
+ "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)."
+ "Proveedor de cuentas"
+ "ejemplo.com"
"Selecciona tu servidor"
+ "Introduce tu proveedor de cuenta"
+ "Elegir proveedor de cuentas"
"Crear cuenta"
"Esta cuenta ha sido eliminada."
"Usuario y/o contraseña incorrectos"
diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml
index dc37ed3e38..a27bc18e3e 100644
--- a/features/login/impl/src/main/res/values-et/translations.xml
+++ b/features/login/impl/src/main/res/values-et/translations.xml
@@ -25,8 +25,16 @@
"%1$s teenusepakkuja pole lubatud."
"Koduserveri url"
"Sisesta domeeni aadress."
+ "Logi sisse"
+ "Loo kasutajakonto"
"Mis on sinu koduserveri aadress?"
+ "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)."
+ "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)."
+ "Teenusepakkuja"
+ "domeeninäidis.ee"
"Vali oma server"
+ "Sisesta oma kasutajakonto teenusepakkuja"
+ "Vali kasutajakonto teenusepakkuja"
"Loo kasutajakonto"
"See kasutajakonto on kustutatud."
"Vigane kasutajanimi ja/või salasõna"
diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml
index 8561bcfce3..493ddee2f8 100644
--- a/features/login/impl/src/main/res/values-fi/translations.xml
+++ b/features/login/impl/src/main/res/values-fi/translations.xml
@@ -25,8 +25,16 @@
"Palveluntarjoaja %1$s ei ole sallittu."
"Kotipalvelimen osoite"
"Anna verkkotunnuksen osoite."
+ "Kirjaudu sisään"
+ "Luo tili"
"Mikä on palvelimesi osoite?"
+ "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)."
+ "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)."
+ "Palveluntarjoaja"
+ "example.com"
"Valitse palvelimesi"
+ "Anna palveluntarjoajasi"
+ "Valitse palveluntarjoaja"
"Luo tili"
"Tämä tili on poistettu."
"Väärä käyttäjänimi ja/tai salasana"
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 504517aad7..0af658d34f 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -25,8 +25,16 @@
"Le fournisseur de compte %1$s n’est pas autorisé."
"URL du serveur d’accueil"
"Saisissez une adresse de domaine."
+ "Se connecter"
+ "Créer un compte"
"Quelle est l’adresse de votre serveur ?"
+ "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)."
+ "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)."
+ "Fournisseur de compte"
+ "exemple.com"
"Choisissez votre serveur"
+ "Saisissez votre fournisseur de compte"
+ "Choisissez un fournisseur de compte"
"Créer un compte"
"Ce compte a été supprimé."
"Nom d’utilisateur et/ou mot de passe incorrects"
diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml
index 1d29c5b619..34ba1bd0ed 100644
--- a/features/login/impl/src/main/res/values-in/translations.xml
+++ b/features/login/impl/src/main/res/values-in/translations.xml
@@ -25,8 +25,16 @@
"Penyedia akun %1$s tidak diizinkan."
"URL Homeserver"
"Masukkan alamat domain."
+ "Login"
+ "Buat akun"
"Apa alamat server Anda?"
+ "Jika akun Anda disediakan oleh perusahaan atau komunitas Anda, maka ubah penyedia akun (contoh: companyname.com)."
+ "Buat akun di matrix.org secara gratis. Jika anda ingin membuat akun dengan penyedia lain, ubah sesuai kebutuhan (contoh: communityname.org)."
+ "Penyedia akun"
+ "contoh.com"
"Pilih server Anda"
+ "Masukkan penyedia akun Anda"
+ "Pilih penyedia akun"
"Buat akun"
"Akun ini telah dinonaktifkan."
"Nama pengguna dan/atau kata sandi salah"
diff --git a/features/login/impl/src/main/res/values-ja/translations.xml b/features/login/impl/src/main/res/values-ja/translations.xml
index 42698e27dc..b4d6f16721 100644
--- a/features/login/impl/src/main/res/values-ja/translations.xml
+++ b/features/login/impl/src/main/res/values-ja/translations.xml
@@ -24,8 +24,16 @@
"アカウント提供元 %1$s は許可されていません。"
"ホームサーバーURL"
"ドメイン名を入力してください"
+ "サインイン"
+ "アカウントを作成"
"サーバーのアドレスは何ですか?"
+ "アカウントが会社やコミュニティから発行されている場合は、アカウント提供元を変更してください (例: kaisha-mei.com)。"
+ "matrix.org 上に無料でアカウントを作成できます。異なるアカウント提供元上にアカウントを作成したい場合は、それにならって変更してください (例: dantai-mei.org)。"
+ "アカウント提供元"
+ "example.com"
"サーバーを選択"
+ "アカウント提供元を入力"
+ "アカウント提供元を選択"
"アカウントを作成"
"アカウントは削除されました。"
"ユーザー名またはパスワードが違います"
diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml
index 634d856cdf..81d8570e32 100644
--- a/features/login/impl/src/main/res/values-pl/translations.xml
+++ b/features/login/impl/src/main/res/values-pl/translations.xml
@@ -25,8 +25,16 @@
"Dostawca konta %1$s jest niedozwolony."
"URL serwera domowego"
"Wprowadź adres domeny."
+ "Zaloguj"
+ "Utwórz konto"
"Jaki jest adres Twojego serwera?"
+ "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)"
+ "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)."
+ "Dostawca konta"
+ "przykład.com"
"Wybierz swój serwer"
+ "Wprowadź dostawcę swojego konta"
+ "Wybierz dostawcę konta"
"Utwórz konto"
"To konto zostało usunięte."
"Nieprawidłowa nazwa użytkownika i/lub hasło"
diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml
index b44242abf8..6835c0a15d 100644
--- a/features/login/impl/src/main/res/values-ro/translations.xml
+++ b/features/login/impl/src/main/res/values-ro/translations.xml
@@ -25,8 +25,16 @@
"Furnizorul de cont %1$s nu este permis."
"Adresa URL a homeserver-ului"
"Introduceți o adresă de domeniu."
+ "Autentificare"
+ "Creați un cont"
"Care este adresa serverului dumneavoastră?"
+ "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)."
+ "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)."
+ "Furnizor de cont"
+ "exemplu.ro"
"Selectați serverul dumneavoastra"
+ "Introduceți furnizorul contului dumneavoastră"
+ "Alegeți un furnizor de cont"
"Creați un cont"
"Acest cont a fost șters."
"Utilizator și/sau parolă incorecte"
diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml
index 60959c80d4..918caf5530 100644
--- a/features/login/impl/src/main/res/values-uk/translations.xml
+++ b/features/login/impl/src/main/res/values-uk/translations.xml
@@ -25,8 +25,16 @@
"Постачальник облікового запису %1$s не дозволений."
"URL-адреса домашнього сервера"
"Введіть адресу домену."
+ "Увійти"
+ "Створити обліковий запис"
"Яка адреса вашого сервера?"
+ "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)."
+ "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)."
+ "Постачальник облікового запису"
+ "example.com"
"Виберіть свій сервер"
+ "Вкажіть постачальника вашого облікового запису"
+ "Оберіть постачальника облікового запису"
"Створити обліковий запис"
"Цей обліковий запис було видалено."
"Неправильне ім\'я користувача та/або пароль"
diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml
index 16afe7d5bd..85de302dc1 100644
--- a/features/login/impl/src/main/res/values-zh/translations.xml
+++ b/features/login/impl/src/main/res/values-zh/translations.xml
@@ -25,8 +25,16 @@
"账户提供者 %1$s 不被允许。"
"主服务器 URL"
"输入域名地址。"
+ "登录"
+ "创建账户"
"你的服务器地址是什么?"
+ "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。"
+ "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。"
+ "账户提供者"
+ "example.com"
"选择服务器"
+ "输入你的账户提供者"
+ "选择账户提供者"
"创建账户"
"此账户已被删除。"
"用户名与(或)密码不正确"
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index 10fb6ef04a..7e798081f5 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -25,8 +25,16 @@
"Account provider %1$s not allowed."
"Homeserver URL"
"Enter a domain address."
+ "Sign in"
+ "Create account"
"What is the address of your server?"
+ "If your account is provided by your company or a community, then change the account provider (example: companyname.com)."
+ "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."
+ "Account provider"
+ "example.com"
"Select your server"
+ "Enter your account provider"
+ "Choose an account provider"
"Create account"
"This account has been deleted."
"Incorrect username and/or password"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
index a05194d008..8a0bf4c307 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
@@ -12,12 +12,11 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.api.LoginEntryPoint
-import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.preferences.test.FakePreferencesEntryPoint
-import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
@@ -38,7 +37,7 @@ class DefaultLoginEntryPointTest {
LoginFlowNode(
buildContext = buildContext,
plugins = plugins,
- accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource = anAccountProviderDataSource(),
oAuthActionFlow = FakeOAuthActionFlow(),
appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(),
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt
new file mode 100644
index 0000000000..25bc67144b
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.test.FakeEnterpriseService
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+
+fun anAccountProviderDataSource(
+ enterpriseService: EnterpriseService = FakeEnterpriseService(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
+) = AccountProviderDataSource(
+ enterpriseService = enterpriseService,
+ appPreferencesStore = appPreferencesStore,
+ coroutineScope = coroutineScope,
+)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
index f86df13335..30e25c8c3e 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
@@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -24,7 +25,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state`() = runTest {
- val sut = AccountProviderDataSource(FakeEnterpriseService())
+ val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
@@ -41,10 +42,10 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state - matrix org`() = runTest {
- val sut = AccountProviderDataSource(
- FakeEnterpriseService(
+ val sut = anAccountProviderDataSource(
+ enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
- )
+ ),
)
sut.flow.test {
val initialState = awaitItem()
@@ -62,10 +63,10 @@ class AccountProviderDataSourceTest {
@Test
fun `present - ensure that default homeserver is not star char`() = runTest {
- val sut = AccountProviderDataSource(
- FakeEnterpriseService(
+ val sut = anAccountProviderDataSource(
+ enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) }
- )
+ ),
)
sut.flow.test {
val initialState = awaitItem()
@@ -83,7 +84,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - user change and reset`() = runTest {
- val sut = AccountProviderDataSource(FakeEnterpriseService())
+ val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
@@ -106,7 +107,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - set url and reset`() = runTest {
- val sut = AccountProviderDataSource(FakeEnterpriseService())
+ val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
@@ -126,4 +127,47 @@ class AccountProviderDataSourceTest {
assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
}
}
+
+ @Test
+ fun `present - defaults to the most recently used provider from history`() = runTest {
+ val sut = anAccountProviderDataSource(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://example.com", "https://matrix.org"),
+ ),
+ )
+ sut.flow.test {
+ assertThat(awaitItem().url).isEqualTo("https://example.com")
+ }
+ }
+
+ @Test
+ fun `present - history is ignored when the account provider is enforced`() = runTest {
+ val sut = anAccountProviderDataSource(
+ enterpriseService = FakeEnterpriseService(
+ defaultHomeserverListResult = { listOf("https://enforced.org") }
+ ),
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://example.com"),
+ ),
+ )
+ sut.flow.test {
+ assertThat(awaitItem().url).isEqualTo("https://enforced.org")
+ }
+ }
+
+ @Test
+ fun `present - reset returns to the most recently used provider from history`() = runTest {
+ val sut = anAccountProviderDataSource(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://example.com"),
+ ),
+ )
+ sut.flow.test {
+ assertThat(awaitItem().url).isEqualTo("https://example.com")
+ sut.setUrl("https://other.com")
+ assertThat(awaitItem().url).isEqualTo("https://other.com")
+ sut.reset()
+ assertThat(awaitItem().url).isEqualTo("https://example.com")
+ }
+ }
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt
new file mode 100644
index 0000000000..0af8188f3f
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class SaveAccountProviderToHistoryTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `invoke saves the current account provider url to history`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ val accountProviderDataSource = anAccountProviderDataSource()
+ accountProviderDataSource.setUrl("https://example.com")
+ val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore)
+
+ sut()
+
+ assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
+ .containsExactly("https://example.com")
+ }
+
+ @Test
+ fun `invoke saves the selected provider even after the exposed default has been reset`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ val accountProviderDataSource = anAccountProviderDataSource(appPreferencesStore = appPreferencesStore)
+ accountProviderDataSource.setUrl("https://example.com")
+ // Simulate the login flow being torn down (e.g. across an OAuth round-trip), which reverts the
+ // exposed default; the user's selection must still be the one saved to history.
+ accountProviderDataSource.reset()
+ val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore)
+
+ sut()
+
+ assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
+ .containsExactly("https://example.com")
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
index 71663c5e7f..ad997111e2 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
@@ -14,6 +14,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData
@@ -66,7 +67,7 @@ class ChangeServerPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
- assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
+ assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true)))
}
}
@@ -244,13 +245,13 @@ class ChangeServerPresenterTest {
while (finalState.changeServerAction !is AsyncData.Success) {
finalState = awaitItem()
}
- assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
+ assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true)))
}
}
private fun createPresenter(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt
deleted file mode 100644
index 38e04b6e7f..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt
+++ /dev/null
@@ -1,108 +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.resolver
-
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
-import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
-import io.element.android.tests.testutils.lambda.lambdaRecorder
-import io.element.android.tests.testutils.lambda.value
-import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class HomeserverResolverTest {
- @Test
- fun `resolve - a server name is used as is`() = runTest {
- val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") }
- val sut = createHomeserverResolver(checkResult)
- sut.resolve("example.org").test {
- assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
- awaitComplete()
- }
- checkResult.assertions().isCalledExactly(1)
- .withSequence(
- listOf(value("https://example.org")),
- )
- }
-
- @Test
- fun `resolve - an url is used as is`() = runTest {
- val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") }
- val sut = createHomeserverResolver(checkResult)
- sut.resolve("https://example.org").test {
- assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
- awaitComplete()
- }
- checkResult.assertions().isCalledExactly(1)
- .withSequence(
- listOf(value("https://example.org")),
- )
- }
-
- @Test
- fun `resolve - a user id resolves the server name of the user id`() = runTest {
- val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") }
- val sut = createHomeserverResolver(checkResult)
- sut.resolve("@alice:example.org").test {
- assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
- awaitComplete()
- }
- checkResult.assertions().isCalledExactly(1)
- .withSequence(
- listOf(value("https://example.org")),
- )
- }
-
- @Test
- fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
- val checkResult = lambdaRecorder> { Result.success(it == "https://example.org:8448") }
- val sut = createHomeserverResolver(checkResult)
- sut.resolve("@alice:example.org:8448").test {
- assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
- awaitComplete()
- }
- checkResult.assertions().isCalledExactly(1)
- .withSequence(
- listOf(value("https://example.org:8448")),
- )
- }
-
- @Test
- fun `resolve - an incomplete user id does not resolve anything`() = runTest {
- val checkResult = lambdaRecorder> { Result.success(true) }
- val sut = createHomeserverResolver(checkResult)
- sut.resolve("@alice").test {
- awaitComplete()
- }
- sut.resolve("@alice:").test {
- awaitComplete()
- }
- checkResult.assertions().isNeverCalled()
- }
-
- @Test
- fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
- val checkResult = lambdaRecorder> { Result.success(true) }
- val sut = createHomeserverResolver(checkResult)
- sut.resolve("@alice:a.b").test {
- awaitComplete()
- }
- checkResult.assertions().isNeverCalled()
- }
-
- private fun TestScope.createHomeserverResolver(
- checkResult: (String) -> Result,
- ) = HomeserverResolver(
- dispatchers = testCoroutineDispatchers(),
- homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
- localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
- )
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
deleted file mode 100644
index 7d81f05c0c..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.enterprise.api.EnterpriseService
-import io.element.android.features.enterprise.test.FakeEnterpriseService
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
-import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
-import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.test
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class ChangeAccountProviderPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val presenter = ChangeAccountProviderPresenter(
- changeServerPresenter = { aChangeServerState() },
- enterpriseService = FakeEnterpriseService(
- defaultHomeserverListResult = { emptyList() }
- ),
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.accountProviders).isEqualTo(
- listOf(
- AccountProvider(
- url = "https://matrix.org",
- title = "matrix.org",
- subtitle = null,
- isPublic = true,
- isMatrixOrg = true,
- )
- )
- )
- assertThat(initialState.canSearchForAccountProviders).isTrue()
- }
- }
-
- @Test
- fun `present - fixed list of account providers`() = runTest {
- val presenter = ChangeAccountProviderPresenter(
- changeServerPresenter = { aChangeServerState() },
- enterpriseService = FakeEnterpriseService(
- defaultHomeserverListResult = {
- listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2)
- }
- ),
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.accountProviders).isEqualTo(
- listOf(
- AccountProvider(
- url = "https://matrix.org",
- title = "matrix.org",
- subtitle = null,
- isPublic = true,
- isMatrixOrg = true,
- ),
- AccountProvider(
- url = "https://element.io",
- title = "element.io",
- subtitle = null,
- isPublic = false,
- isMatrixOrg = false,
- )
- )
- )
- assertThat(initialState.canSearchForAccountProviders).isFalse()
- }
- }
-
- @Test
- fun `present - opened list of account providers`() = runTest {
- val presenter = ChangeAccountProviderPresenter(
- changeServerPresenter = { aChangeServerState() },
- enterpriseService = FakeEnterpriseService(
- defaultHomeserverListResult = {
- listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER)
- }
- ),
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.accountProviders).isEqualTo(
- listOf(
- AccountProvider(
- url = "https://matrix.org",
- title = "matrix.org",
- subtitle = null,
- isPublic = true,
- isMatrixOrg = true,
- )
- )
- )
- assertThat(initialState.canSearchForAccountProviders).isTrue()
- }
- }
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt
index 8d56386fa4..d4a1a74d99 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt
@@ -8,8 +8,8 @@
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
@@ -249,7 +249,7 @@ private fun createPresenter(
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
isEnterpriseBuild: Boolean = false,
) = LoginWithClassicPresenter(
userId = userId,
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
index 52858b77af..641eea1563 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
@@ -8,10 +8,16 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
+import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
+import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
+import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
+import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
+import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
@@ -22,7 +28,12 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
-import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
+import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -40,7 +51,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem()
assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue()
- assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
+ assertThat(initialState.accountProviderInput).isEqualTo("matrix.org")
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -57,14 +68,10 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -80,14 +87,10 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -105,18 +108,14 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
- val cancelFailureState = awaitItem()
+ val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -134,17 +133,13 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
- val cancelFinalState = awaitItem()
+ val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -162,13 +157,12 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ awaitLoginMode { it is AsyncData.Loading }
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
- val cancelFinalState = awaitItem()
+ val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -186,20 +180,14 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
- val cancelLoadingState = awaitItem()
- assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val cancelFailureState = awaitItem()
+ val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -217,17 +205,13 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
- val successSuccessState = awaitItem()
+ val successSuccessState = awaitLoginMode { it is AsyncData.Loading }
assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -243,11 +227,11 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
- val failureState = awaitItem()
- assertThat(failureState.submitEnabled).isFalse()
- assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ // The account provider validation fails, so the login is never attempted.
+ val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
+ assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -264,18 +248,15 @@ class ConfirmAccountProviderPresenterTest {
presenter.test {
val initialState = awaitItem()
- // Submit will return an error
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
-
- skipItems(1) // Loading
+ // Submit will return an error while validating the account provider
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
- val submittedState = awaitItem()
- assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
- val clearedState = awaitItem()
+ val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -284,7 +265,7 @@ class ConfirmAccountProviderPresenterTest {
fun `present - confirm account creation without oidc generates an error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails())
+ Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
@@ -293,15 +274,15 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
- val submittedState = awaitItem()
+ val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
- val clearedState = awaitItem()
+ val clearedState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -318,25 +299,286 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
- val submittedState = awaitItem()
- assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - offers an autocomplete suggestion from the account provider history`() = runTest {
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://randomcommunity.org"),
+ ),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.accountProviderSuggestion).isNull()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
+ val suggestionState = awaitState { it.accountProviderInput == "random" }
+ // Completion is offered without the https:// scheme, even though history stores the full URL.
+ assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org")
+ }
+ }
+
+ @Test
+ fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://randomcommunity.org"),
+ ),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
+ val suggestionState = awaitState { it.accountProviderSuggestion != null }
+ suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue applies the completed account provider back into the field`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
+ val suggestionState = awaitState { it.accountProviderSuggestion != null }
+ suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
+ // The field now renders the full accepted server rather than the typed prefix.
+ val appliedState = awaitState { it.accountProviderInput == "randomcommunity.org" }
+ assertThat(appliedState.accountProviderInput).isEqualTo("randomcommunity.org")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - offers the enterprise allow-list servers as autocomplete suggestions`() = runTest {
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
+ enterpriseService = FakeEnterpriseService(
+ // Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete.
+ defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) },
+ ),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("ele"))
+ val suggestionState = awaitState { it.accountProviderInput == "ele" }
+ assertThat(suggestionState.accountProviderSuggestion).isEqualTo("element.io")
+ }
+ }
+
+ @Test
+ fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest {
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("matr"))
+ val suggestionState = awaitState { it.accountProviderInput == "matr" }
+ assertThat(suggestionState.accountProviderSuggestion).isEqualTo("matrix.org")
+ }
+ }
+
+ @Test
+ fun `present - does not autocomplete a scheme-prefixed input`() = runTest {
+ // Candidates are bare hosts, so a scheme-prefixed input never completes them - the user completes by
+ // typing the bare host. This also avoids ever suggesting an insecure http scheme.
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://mat"))
+ assertThat(awaitState { it.accountProviderInput == "https://mat" }.accountProviderSuggestion).isNull()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("http://mat"))
+ assertThat(awaitState { it.accountProviderInput == "http://mat" }.accountProviderSuggestion).isNull()
+ }
+ }
+
+ @Test
+ fun `present - continue prepends https to a bare account provider`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue configures the homeserver only once`() = runTest {
+ // The consolidated Continue flow validates the account provider and then signs in. Login reuses the
+ // details resolved during validation, so setHomeserver runs exactly once rather than once per phase.
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a full matrix id signs in to its homeserver and passes a login hint`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService)
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo("https://example.org")
+ assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a matrix id keeps the port in the homeserver`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService)
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org:8448"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo("https://example.org:8448")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a plain account provider passes no login hint`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
+ )
+ val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService)
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(authenticationService.getOAuthUrlLoginHint).isNull()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a matrix id during account creation passes no login hint`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
+ matrixAuthenticationService = authenticationService,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(authenticationService.getOAuthUrlLoginHint).isNull()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ /**
+ * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate
+ * account-provider validation states, and returns that state.
+ */
+ private suspend fun ReceiveTurbine.awaitLoginMode(
+ predicate: (AsyncData) -> Boolean,
+ ): ConfirmAccountProviderState = awaitState { predicate(it.loginModeState.loginMode) }
+
+ private suspend fun ReceiveTurbine.awaitState(
+ predicate: (ConfirmAccountProviderState) -> Boolean,
+ ): ConfirmAccountProviderState {
+ while (true) {
+ val item = awaitItem()
+ if (predicate(item)) return item
}
}
private fun createConfirmAccountProviderPresenter(
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ enterpriseService: EnterpriseService = FakeEnterpriseService(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
+ appPreferencesStore = appPreferencesStore,
+ enterpriseService = enterpriseService,
loginModePresenter = createLoginModePresenter(
authenticationService = matrixAuthenticationService,
oAuthActionFlow = defaultOAuthActionFlow,
),
+ changeServerPresenter = ChangeServerPresenter(
+ authenticationService = matrixAuthenticationService,
+ accountProviderDataSource = accountProviderDataSource,
+ defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
+ ),
+ isEnterpriseBuild = { false },
+ ),
+ localNetworkPermissionGate = LocalNetworkPermissionGate(
+ advisor = FakeLocalNetworkPermissionAdvisor(),
+ permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()),
+ ),
+ ),
)
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
index 31a835cb8c..4dd2859dee 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
@@ -10,8 +10,9 @@ package io.element.android.features.login.impl.screens.loginpassword
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -21,8 +22,11 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.A_USER_NAME_2
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -101,6 +105,32 @@ class LoginPasswordPresenterTest {
}
}
+ @Test
+ fun `present - successful login saves the account provider to history`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = {
+ Result.success(aMatrixHomeServerDetails())
+ },
+ )
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ createLoginPasswordPresenter(
+ authenticationService = authenticationService,
+ appPreferencesStore = appPreferencesStore,
+ ).test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
+ initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
+ skipItems(1)
+ val loginAndPasswordState = awaitItem()
+ loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
+ skipItems(1)
+ val loggedInState = awaitItem()
+ assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID))
+ assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
+ .containsExactly(AuthenticationConfig.MATRIX_ORG_URL)
+ }
+ }
+
@Test
fun `present - submit with error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
@@ -157,10 +187,12 @@ class LoginPasswordPresenterTest {
private fun createLoginPasswordPresenter(
initialLogin: String = "",
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
): LoginPasswordPresenter = LoginPasswordPresenter(
initialLogin = initialLogin,
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
+ saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore),
)
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
index df3e3d2200..3c2e4b574f 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
@@ -16,6 +16,8 @@ import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.libraries.architecture.AsyncData
@@ -31,11 +33,12 @@ import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow
-import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -248,7 +251,7 @@ class OnBoardingPresenterTest {
Result.failure(AN_EXCEPTION)
},
)
- val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService())
+ val accountProviderDataSource = anAccountProviderDataSource()
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
@@ -299,7 +302,7 @@ private fun createPresenter(
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
sessionStore: SessionStore = InMemorySessionStore(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
@@ -322,6 +325,8 @@ fun createLoginModePresenter(
FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: PermissionsPresenter.Factory =
FakePermissionsPresenterFactory(),
+ saveAccountProviderToHistory: SaveAccountProviderToHistory =
+ SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()),
): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
@@ -329,4 +334,5 @@ fun createLoginModePresenter(
advisor = localNetworkPermissionAdvisor,
permissionsPresenterFactory = permissionsPresenterFactory,
),
+ saveAccountProviderToHistory = saveAccountProviderToHistory,
)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
deleted file mode 100644
index 29c5d4a86c..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverResolver
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
-import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
-import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.lambda.lambdaRecorder
-import io.element.android.tests.testutils.lambda.value
-import io.element.android.tests.testutils.test
-import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class SearchAccountProviderPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) })
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.userInput).isEmpty()
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- }
- }
-
- @Test
- fun `present - error while checking login compatibility`() = runTest {
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) })
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("https://test.org")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.org")
- )
- )
- )
- }
- }
-
- @Test
- fun `present - enter text no result`() = runTest {
- val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) })
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("test")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test"),
- )
- )
- )
- }
- }
-
- @Test
- fun `present - enter text one result with wellknown`() = runTest {
- val checkResult = lambdaRecorder> {
- when (it) {
- "https://test.org" -> Result.success(false)
- "https://test.com" -> Result.success(false)
- "https://test.io" -> Result.success(true)
- "https://test" -> Result.success(false)
- else -> error("should not happen")
- }
- }
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("test")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.io")
- )
- )
- )
- checkResult.assertions().isCalledExactly(4)
- .withSequence(
- listOf(value("https://test.org")),
- listOf(value("https://test.com")),
- listOf(value("https://test.io")),
- listOf(value("https://test")),
- )
- }
- }
-
- @Test
- fun `present - enter text two results with wellknown`() = runTest {
- val checkResult = lambdaRecorder> {
- when (it) {
- "https://test.org" -> Result.success(true)
- "https://test.com" -> Result.success(false)
- "https://test.io" -> Result.success(true)
- "https://test" -> Result.success(false)
- else -> error("should not happen")
- }
- }
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("test")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.org"),
- )
- )
- )
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.org"),
- aHomeserverData(homeserverUrl = "https://test.io"),
- )
- )
- )
- checkResult.assertions().isCalledExactly(4)
- .withSequence(
- listOf(value("https://test.org")),
- listOf(value("https://test.com")),
- listOf(value("https://test.io")),
- listOf(value("https://test")),
- )
- }
- }
-}
diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts
index 7073648dd2..4cbf0910db 100644
--- a/features/login/test/build.gradle.kts
+++ b/features/login/test/build.gradle.kts
@@ -15,6 +15,7 @@ android {
}
dependencies {
+ implementation(projects.libraries.architecture)
implementation(projects.features.login.api)
implementation(projects.tests.testutils)
}
diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt
new file mode 100644
index 0000000000..2c94144890
--- /dev/null
+++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.test
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.features.login.api.LoginEntryPoint
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeLoginEntryPoint(
+ private val createNodeResult: (BuildContext, LoginEntryPoint.Params) -> Node = { _, _ -> lambdaError() },
+) : LoginEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ params: LoginEntryPoint.Params,
+ callback: LoginEntryPoint.Callback,
+ ): Node = createNodeResult(buildContext, params)
+}
diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt
new file mode 100644
index 0000000000..9ebd3be7fc
--- /dev/null
+++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.test.accesscontrol
+
+import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeAccountProviderAccessControl(
+ private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() },
+) : AccountProviderAccessControl {
+ override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean {
+ return isAllowedToConnectToAccountProviderResult(accountProviderUrl)
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index dd3afb0ea6..4af0c05955 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -400,9 +400,23 @@ class MessagesPresenter(
TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit
+ TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
}
}
+ private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
+ val sendHandle = targetEvent.sendhandle ?: return Unit.also {
+ Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
+ }
+ sendHandle.retry()
+ .onSuccess {
+ Timber.d("Succeed to add the message back to the send queue")
+ }
+ .onFailure {
+ Timber.e(it, "Failed to add the message back to the send queue")
+ }
+ }
+
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
timelineController.invokeOnCurrentTimeline {
editCaption(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 5911de7372..0476d0de46 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@@ -178,8 +179,8 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
- val eventId = event.eventId ?: return
- if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
+ val eventId = event.eventId
+ if (eventId != null && eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
@@ -429,6 +430,31 @@ fun MessagesView(
},
state = state.linkState,
)
+
+ SendFailureDialogView(
+ sendFailureDialogState = state.timelineState.sendFailureDialogState,
+ onDismiss = {
+ state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
+ },
+ onRetry = { event ->
+ state.eventSink(
+ MessagesEvent.HandleAction(
+ action = TimelineItemAction.RetrySending,
+ event = event,
+ )
+ )
+ state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
+ },
+ onRemoveMessage = { event ->
+ state.eventSink(
+ MessagesEvent.HandleAction(
+ action = TimelineItemAction.Redact,
+ event = event,
+ )
+ )
+ state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
+ },
+ )
}
@Composable
@@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents(
when {
state.successorRoom != null -> {
SuccessorRoomBanner(
- modifier = Modifier.fillMaxWidth().padding(contentPadding),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(contentPadding),
roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick
)
}
state.userEventPermissions.canSendMessage -> {
- Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(contentPadding)
+ ) {
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
index 25e75beb99..3c64223e59 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
@@ -36,4 +36,7 @@ enum class TimelineItemAction(
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
+
+ // Note: this action is not used in UI for now
+ RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
index 0a0d9b17af..2f6fa4e4e0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
@@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator {
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
+ // Not rendered in the action list, but has to be listed here for exhaustiveness.
+ TimelineItemAction.RetrySending,
)
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt
index 73ba08b29d..9be11d244a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt
@@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.pinned.list
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.ThreadId
+import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
sealed interface PinnedMessagesListEvent {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent
data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent
+ data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : PinnedMessagesListEvent
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
index 06d97f167c..36ae68de9b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
@@ -50,6 +51,7 @@ class PinnedMessagesListNode(
actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
+ private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
@@ -134,6 +136,7 @@ class PinnedMessagesListNode(
toastMessage = toastMessage,
)
},
+ emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
index e1ee18e920..748fa084f8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
@@ -30,6 +30,8 @@ import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -47,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
+import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -71,6 +74,8 @@ class PinnedMessagesListPresenter(
private val linkPresenter: Presenter,
private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter,
+ private val customReactionPresenter: Presenter,
+ private val reactionSummaryPresenter: Presenter,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
@@ -88,7 +93,7 @@ class PinnedMessagesListPresenter(
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
- computeReactions = false,
+ computeReactions = true,
)
)
@@ -96,6 +101,9 @@ class PinnedMessagesListPresenter(
override fun present(): PinnedMessagesListState {
htmlConverterProvider.Update()
val roomInfo by room.roomInfoFlow.collectAsState()
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val timelineRoomInfo by remember {
derivedStateOf {
TimelineRoomInfo(
@@ -103,7 +111,7 @@ class PinnedMessagesListPresenter(
name = roomInfo.name,
// We don't need to compute those values
userHasPermissionToSendMessage = false,
- userHasPermissionToSendReaction = false,
+ userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
// We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown
@@ -119,9 +127,8 @@ class PinnedMessagesListPresenter(
}
val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
- val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
- perms.userEventPermissions()
- }
+ val customReactionState = customReactionPresenter.present()
+ val reactionSummaryState = reactionSummaryPresenter.present()
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
@@ -138,6 +145,7 @@ class PinnedMessagesListPresenter(
when (event) {
is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId)
+ is PinnedMessagesListEvent.ToggleReaction -> sessionCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
}
}
@@ -148,10 +156,20 @@ class PinnedMessagesListPresenter(
displayThreadSummaries = displayThreadSummaries,
userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems,
+ customReactionState = customReactionState,
+ reactionSummaryState = reactionSummaryState,
eventSink = ::handleEvent,
)
}
+ private fun CoroutineScope.toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId) {
+ launch {
+ timelineProvider.invokeOnTimeline {
+ toggleReaction(emoji, eventOrTransactionId)
+ }
+ }
+ }
+
private fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: TimelineItem.Event,
@@ -234,6 +252,8 @@ class PinnedMessagesListPresenter(
linkState: LinkState,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData>,
+ customReactionState: CustomReactionState,
+ reactionSummaryState: ReactionSummaryState,
eventSink: (PinnedMessagesListEvent) -> Unit
): PinnedMessagesListState {
return when (timelineItems) {
@@ -252,6 +272,8 @@ class PinnedMessagesListPresenter(
userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data,
actionListState = actionListState,
+ customReactionState = customReactionState,
+ reactionSummaryState = reactionSummaryState,
eventSink = eventSink
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt
index bf37029eff..6c55a0d90a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt
@@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.ui.strings.CommonPlurals
@@ -34,6 +36,8 @@ sealed interface PinnedMessagesListState {
val timelineItems: ImmutableList,
val actionListState: ActionListState,
val linkState: LinkState,
+ val customReactionState: CustomReactionState,
+ val reactionSummaryState: ReactionSummaryState,
val displayThreadSummaries: Boolean,
val eventSink: (PinnedMessagesListEvent) -> Unit,
) : PinnedMessagesListState {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt
index bfa9820a44..19f19da0de 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.UserEventPermissions
+import io.element.android.features.messages.impl.aCustomReactionState
+import io.element.android.features.messages.impl.aReactionSummaryState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.link.LinkState
@@ -19,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
@@ -92,6 +96,8 @@ fun aLoadedPinnedMessagesListState(
linkState: LinkState = aLinkState(),
timelineItems: List = emptyList(),
actionListState: ActionListState = anActionListState(),
+ customReactionState: CustomReactionState = aCustomReactionState(),
+ reactionSummaryState: ReactionSummaryState = aReactionSummaryState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
displayThreadSummaries: Boolean = false,
eventSink: (PinnedMessagesListEvent) -> Unit = {}
@@ -101,6 +107,8 @@ fun aLoadedPinnedMessagesListState(
linkState = linkState,
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
+ customReactionState = customReactionState,
+ reactionSummaryState = reactionSummaryState,
userEventPermissions = aUserEventPermissions,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
index 4f69bacd28..0b76d35d36 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
@@ -33,8 +33,12 @@ import io.element.android.features.messages.impl.link.LinkEvent
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
@@ -50,6 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
+import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
+import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -66,6 +72,7 @@ fun PinnedMessagesListView(
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
+ emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -89,6 +96,7 @@ fun PinnedMessagesListView(
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onErrorDismiss = onBackClick,
+ emojiPickerRenderer = emojiPickerRenderer,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
@@ -121,6 +129,7 @@ private fun PinnedMessagesListContent(
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onErrorDismiss: () -> Unit,
+ emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
@@ -141,6 +150,7 @@ private fun PinnedMessagesListContent(
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
+ emojiPickerRenderer = emojiPickerRenderer,
)
PinnedMessagesListState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -180,6 +190,7 @@ private fun PinnedMessagesListLoaded(
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
+ emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
@@ -203,11 +214,25 @@ private fun PinnedMessagesListLoaded(
)
}
+ fun onReactionClick(emoji: String, event: TimelineItem.Event) {
+ state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, event.eventOrTransactionId))
+ }
+
+ fun onReactionLongClick(emoji: String, event: TimelineItem.Event) {
+ event.eventId?.let { eventId ->
+ state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(eventId, event.reactionsState.reactions, emoji))
+ }
+ }
+
+ fun onMoreReactionsClick(event: TimelineItem.Event) {
+ state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
+ }
+
ActionListView(
state = state.actionListState,
onSelectAction = ::onActionSelected,
- onCustomReactionClick = {},
- onEmojiReactionClick = { _, _ -> },
+ onCustomReactionClick = ::onMoreReactionsClick,
+ onEmojiReactionClick = ::onReactionClick,
onVerifiedUserSendFailureClick = {}
)
@@ -239,9 +264,9 @@ private fun PinnedMessagesListLoaded(
onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {},
- onReactionClick = { _, _ -> },
- onReactionLongClick = { _, _ -> },
- onMoreReactionsClick = {},
+ onReactionClick = ::onReactionClick,
+ onReactionLongClick = ::onReactionLongClick,
+ onMoreReactionsClick = ::onMoreReactionsClick,
onReadReceiptClick = {},
onSwipeToReply = {},
onJoinCallClick = {},
@@ -273,6 +298,14 @@ private fun PinnedMessagesListLoaded(
state.linkState,
onLinkValid = onLinkClick,
)
+ CustomReactionBottomSheet(
+ state = state.customReactionState,
+ onSelectEmoji = { eventOrTransactionId, emoji ->
+ state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId))
+ },
+ emojiPickerRenderer = emojiPickerRenderer,
+ )
+ ReactionSummaryView(state = state.reactionSummaryState)
}
@Composable
@@ -322,5 +355,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
onUserDataClick = {},
onLinkClick = {},
onLinkLongClick = {},
+ emojiPickerRenderer = NoOpEmojiPickerRenderer,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt
index cfc3672ca3..97608e15fa 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt
@@ -34,6 +34,9 @@ sealed interface TimelineEvent {
*/
sealed interface TimelineItemEvent : TimelineEvent
+ data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent
+ data object HideSendFailureDialog : TimelineEvent
+
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent
data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 7cd0d018d2..43913c43bb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
@@ -142,6 +144,7 @@ class TimelinePresenter(
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShieldDialogData: MutableState = remember { mutableStateOf(null) }
+ var sendFailureDialogState by remember { mutableStateOf(SendFailureDialogState.Hidden) }
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
@@ -166,9 +169,14 @@ class TimelinePresenter(
fun handleEvent(event: TimelineEvent) {
when (event) {
is TimelineEvent.LoadMore -> {
- if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) {
- // Do not paginate forwards in thread mode, as it's not supported
- return
+ if (event.direction == Timeline.PaginationDirection.FORWARDS) {
+ if (timelineMode is Timeline.Mode.Thread) {
+ // Do not paginate forwards in thread mode, as it's not supported
+ return
+ }
+ if (focusRequestState.value.isPending()) {
+ return
+ }
}
localScope.launch {
timelineController.paginate(direction = event.direction)
@@ -244,6 +252,47 @@ class TimelinePresenter(
suppressJumpToUnread.value = true
}
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
+ is TimelineEvent.ShowSendFailureDialog -> {
+ val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
+ when (sendStateFailure) {
+ is LocalEventSendState.Failed.VerifiedUser -> {
+ // defer to the resolveVerifiedUserSendFailureState to handle this case,
+ // as it will show a dialog with the right information
+ resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
+ }
+ is LocalEventSendState.Failed.InvalidMimeType -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType),
+ )
+ }
+ LocalEventSendState.Failed.MissingMediaContent -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
+ )
+ }
+ LocalEventSendState.Failed.SendingFromUnverifiedDevice -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
+ )
+ }
+ is LocalEventSendState.Failed.Unknown -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = if (sendStateFailure.error.isNotBlank()) {
+ SendFailureDialogState.SendFailureType.Error(sendStateFailure.error)
+ } else {
+ SendFailureDialogState.SendFailureType.Unknown
+ }
+ )
+ }
+ }
+ }
+ is TimelineEvent.HideSendFailureDialog -> {
+ sendFailureDialogState = SendFailureDialogState.Hidden
+ }
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
}
@@ -408,6 +457,7 @@ class TimelinePresenter(
focusRequestState = focusRequestState.value,
messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
+ sendFailureDialogState = sendFailureDialogState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
@@ -537,6 +587,16 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState {
}
}
+private fun FocusRequestState.isPending(): Boolean {
+ return when (this) {
+ is FocusRequestState.Requested,
+ is FocusRequestState.Loading -> true
+ is FocusRequestState.Success -> !rendered
+ FocusRequestState.None,
+ is FocusRequestState.Failure -> false
+ }
+}
+
// Workaround for not having the server names available, get possible server names from the user ids of the room members
private fun calculateServerNamesForRoom(room: JoinedRoom): List {
// If we have no room members, return right ahead
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index 7836c17563..1ebc96f15f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
@@ -32,6 +33,7 @@ data class TimelineState(
// If not null, info will be rendered in a dialog
val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
+ val sendFailureDialogState: SendFailureDialogState,
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index 61b5c690a4..89545b5d0b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
@@ -57,6 +58,7 @@ fun aTimelineState(
isLive: Boolean = true,
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
+ sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden,
displayThreadSummaries: Boolean = false,
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
@@ -78,6 +80,7 @@ fun aTimelineState(
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
+ sendFailureDialogState = sendFailureDialogState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
index b6af359269..f124d45d99 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
@@ -56,10 +56,11 @@ fun TimelineEventTimestampView(
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when {
shield != null -> stringResource(CommonStrings.a11y_view_details)
- hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
+ isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
+ hasError -> stringResource(CommonStrings.a11y_view_details)
else -> null
}
- val clickableModifier = remember(shield, hasError) {
+ val clickableModifier = remember(event) {
when {
shield != null -> {
Modifier.clickable(
@@ -70,10 +71,9 @@ fun TimelineEventTimestampView(
}
hasError -> Modifier
.clickable(
- enabled = isVerifiedUserSendFailure,
onClickLabel = onClickLabel,
) {
- eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
+ eventSink(TimelineEvent.ShowSendFailureDialog(event))
}
else -> Modifier
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
index 1679a6030a..9bf7aa3c32 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
@@ -21,6 +21,9 @@ import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
@@ -110,7 +113,11 @@ class TimelineItemsFactory(
): TimelineItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
- is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
+ is MatrixTimelineItem.Event -> if (currentTimelineItem.event.isKeyVerificationRequest()) {
+ null
+ } else {
+ eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
+ }
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
@@ -118,3 +125,8 @@ class TimelineItemsFactory(
return timelineItem
}
}
+
+private fun EventTimelineItem.isKeyVerificationRequest(): Boolean {
+ val messageType = (content as? MessageContent)?.type
+ return messageType is OtherMessageType && messageType.isKeyVerificationRequest
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt
new file mode 100644
index 0000000000..02da5a6503
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.timeline.sendfailure
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+
+@Immutable
+sealed interface SendFailureDialogState {
+ data object Hidden : SendFailureDialogState
+
+ data class Show(
+ val event: TimelineItem.Event,
+ val sendFailureType: SendFailureType,
+ ) : SendFailureDialogState
+
+ @Immutable
+ sealed interface SendFailureType {
+ data class InvalidMimeType(val mimeType: String) : SendFailureType
+ data object MissingMediaContent : SendFailureType
+ data object SendingFromUnverifiedDevice : SendFailureType
+ data class Error(val message: String) : SendFailureType
+ data object Unknown : SendFailureType
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt
new file mode 100644
index 0000000000..55c894c0d9
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.timeline.sendfailure
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+
+class SendFailureDialogStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ SendFailureDialogState.Hidden,
+ SendFailureDialogState.Show(
+ event = aTimelineItemEvent(),
+ sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("image/invalid")
+ ),
+ SendFailureDialogState.Show(
+ event = aTimelineItemEvent(),
+ sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
+ ),
+ SendFailureDialogState.Show(
+ event = aTimelineItemEvent(),
+ sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
+ ),
+ SendFailureDialogState.Show(
+ event = aTimelineItemEvent(),
+ sendFailureType = SendFailureDialogState.SendFailureType.Error("Message error from server"),
+ ),
+ SendFailureDialogState.Show(
+ event = aTimelineItemEvent(),
+ sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
+ )
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt
new file mode 100644
index 0000000000..464fc2a509
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.timeline.sendfailure
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.messages.impl.R
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun SendFailureDialogView(
+ sendFailureDialogState: SendFailureDialogState,
+ onDismiss: () -> Unit,
+ onRetry: (TimelineItem.Event) -> Unit,
+ onRemoveMessage: (TimelineItem.Event) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (sendFailureDialogState) {
+ is SendFailureDialogState.Hidden -> Unit
+ is SendFailureDialogState.Show -> {
+ // Show the dialog with the message and actions
+ val content = when (sendFailureDialogState.sendFailureType) {
+ is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource(
+ id = R.string.error_sending_failure_invalid_mime_type,
+ sendFailureDialogState.sendFailureType.mimeType,
+ )
+ is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource(
+ id = R.string.error_sending_failure_missing_media_content,
+ )
+ is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource(
+ id = R.string.error_sending_failure_sending_from_unverified_device,
+ )
+ is SendFailureDialogState.SendFailureType.Error -> stringResource(
+ id = R.string.error_sending_failure_other,
+ sendFailureDialogState.sendFailureType.message
+ )
+ is SendFailureDialogState.SendFailureType.Unknown -> stringResource(
+ id = R.string.error_sending_failure_unknown,
+ )
+ }
+
+ ConfirmationDialog(
+ modifier = modifier,
+ title = stringResource(id = CommonStrings.common_sending_failed),
+ content = content,
+ onDismiss = onDismiss,
+ submitText = stringResource(id = CommonStrings.action_retry),
+ onSubmitClick = { onRetry(sendFailureDialogState.event) },
+ onCancelClick = onDismiss,
+ thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
+ onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun SendFailureDialogViewPreview(
+ @PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState,
+) = ElementPreview {
+ SendFailureDialogView(
+ sendFailureDialogState = sendFailureDialogState,
+ onDismiss = {},
+ onRetry = {},
+ onRemoveMessage = {},
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
index 6aabf97f2c..d6a55c0563 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@@ -84,6 +85,7 @@ internal fun MessagesViewTopBar(
val roundedCornerShape = RoundedCornerShape(8.dp)
Row(
modifier = Modifier
+ .heightIn(min = 48.dp)
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() }
.semantics { heading() },
diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml
index 556ab00ee0..e29b83af41 100644
--- a/features/messages/impl/src/main/res/values/localazy.xml
+++ b/features/messages/impl/src/main/res/values/localazy.xml
@@ -7,6 +7,11 @@
"Encrypted by an unknown or deleted device."
"Encrypted by a device not verified by its owner."
"Encrypted by an unverified user."
+ "Invalid mime type: %s"
+ "Missing media content"
+ "Error details: %s"
+ "Sending from unverified device"
+ "Unknown error"
"Flip image horizontally"
"Flipped horizontally"
"Original"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 0a0d310b16..227088f78c 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -83,6 +84,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
+import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@@ -1043,6 +1045,56 @@ class MessagesPresenterTest {
}
}
+ @Test
+ fun `present - handle action retry sending`() = runTest {
+ val retryLambda = lambdaRecorder> { Result.success(Unit) }
+ val messageEvent = aMessageEvent(
+ sendState = LocalEventSendState.Failed.Unknown("Error"),
+ sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
+ )
+ val presenter = createMessagesPresenter()
+ presenter.testWithLifecycleOwner {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
+ advanceUntilIdle()
+ retryLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - handle action retry sending - failure is ignored`() = runTest {
+ val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) }
+ val messageEvent = aMessageEvent(
+ sendState = LocalEventSendState.Failed.Unknown("Error"),
+ sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
+ )
+ val presenter = createMessagesPresenter()
+ presenter.testWithLifecycleOwner {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
+ advanceUntilIdle()
+ retryLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest {
+ val messageEvent = aMessageEvent(
+ sendState = LocalEventSendState.Failed.Unknown("Error"),
+ sendHandleProvider = { null },
+ )
+ val presenter = createMessagesPresenter()
+ presenter.testWithLifecycleOwner {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
+ advanceUntilIdle()
+ // No op!
+ }
+ }
+
@Test
fun `present - handle action view in timeline, it should have no effect`() = runTest {
val messageEvent = aMessageEvent(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
index 2800c737ad..f662da0eb0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
@@ -55,12 +55,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer
import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -88,6 +90,7 @@ import org.junit.Test
import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds
+@Suppress("LargeClass")
class MessagesViewTest : RobolectricTest() {
@Test
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
@@ -176,6 +179,33 @@ class MessagesViewTest : RobolectricTest() {
callback.assertSuccess()
}
+ @Test
+ fun `clicking on an Event which has not been sent yet invoke expected callback`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ val localEcho = aTimelineItemEvent(
+ isMine = true,
+ content = aTimelineItemTextContent(),
+ ).copy(eventId = null)
+ val state = aMessagesState(
+ timelineState = aTimelineState(
+ timelineItems = persistentListOf(localEcho),
+ ),
+ eventSink = eventsRecorder
+ )
+ val callback = EnsureCalledOnceWithTwoParamsAndResult(
+ expectedParam1 = true,
+ expectedParam2 = localEcho,
+ result = true,
+ )
+ setMessagesView(
+ state = state,
+ onEventClick = callback,
+ )
+ // Cannot perform click on "Text", it's not detected. Use tag instead
+ onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick()
+ callback.assertSuccess()
+ }
+
@Test
fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() {
`long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true)
@@ -531,6 +561,80 @@ class MessagesViewTest : RobolectricTest() {
eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem))
}
+ @Test
+ fun `clicking on retry in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest {
+ val messagesEventsRecorder = EventsRecorder()
+ val timelineEventsRecorder = EventsRecorder()
+ val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
+ setMessagesView(
+ state = aMessagesState(
+ timelineState = aTimelineState(
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = timelineItem,
+ sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
+ ),
+ eventSink = timelineEventsRecorder,
+ ),
+ eventSink = messagesEventsRecorder,
+ ),
+ )
+ // Clear initial 'LoadMore' event emitted when setting the state
+ timelineEventsRecorder.clear()
+
+ clickOn(CommonStrings.action_retry)
+ messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, timelineItem))
+ timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
+ }
+
+ @Test
+ fun `clicking on remove message in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest {
+ val messagesEventsRecorder = EventsRecorder()
+ val timelineEventsRecorder = EventsRecorder()
+ val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
+ setMessagesView(
+ state = aMessagesState(
+ timelineState = aTimelineState(
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = timelineItem,
+ sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
+ ),
+ eventSink = timelineEventsRecorder,
+ ),
+ eventSink = messagesEventsRecorder,
+ ),
+ )
+ // Clear initial 'LoadMore' event emitted when setting the state
+ timelineEventsRecorder.clear()
+
+ clickOn(CommonStrings.action_remove_message)
+ messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem))
+ timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
+ }
+
+ @Test
+ fun `cancelling the send failure dialog emits the expected Event`() = runAndroidComposeUiTest {
+ val messagesEventsRecorder = EventsRecorder(expectEvents = false)
+ val timelineEventsRecorder = EventsRecorder()
+ val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
+ setMessagesView(
+ state = aMessagesState(
+ timelineState = aTimelineState(
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = timelineItem,
+ sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
+ ),
+ eventSink = timelineEventsRecorder,
+ ),
+ eventSink = messagesEventsRecorder,
+ ),
+ )
+ // Clear initial 'LoadMore' event emitted when setting the state
+ timelineEventsRecorder.clear()
+
+ clickOn(CommonStrings.action_cancel)
+ timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
+ }
+
@Test
fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest {
val aUnicode = "🙈"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
index 3efa707c3d..cafb16c41f 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
+import io.element.android.features.messages.impl.aCustomReactionState
+import io.element.android.features.messages.impl.aReactionSummaryState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
@@ -400,6 +402,8 @@ class PinnedMessagesListPresenterTest {
snackbarDispatcher = SnackbarDispatcher(),
actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() },
+ customReactionPresenter = { aCustomReactionState() },
+ reactionSummaryPresenter = { aReactionSummaryState() },
analyticsService = analyticsService,
featureFlagService = featureFlagService,
sessionCoroutineScope = this,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt
index 35e60684a9..856f74e33a 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
+import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
@@ -111,6 +112,7 @@ private fun AndroidComposeUiTest.setPinnedMessagesListView(
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
+ emojiPickerRenderer = NoOpEmojiPickerRenderer,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index 0db690f928..3b20614dac 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -12,6 +12,8 @@ import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.impl.FakeMessagesNavigator
+import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
+import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
@@ -21,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -45,6 +48,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
@@ -1059,6 +1063,169 @@ class TimelinePresenterTest {
}
}
+ @Test
+ fun `present - a forward pagination while the focused event is not rendered yet is ignored`() = runTest {
+ val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
+ Result.success(true)
+ }
+ val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
+ val presenter = createTimelinePresenter(
+ room = room,
+ timeline = room.liveTimeline,
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
+ val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
+ assertThat(focusedState.isLive).isFalse()
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda).isNeverCalled()
+ assertThat(expectMostRecentItem().isLive).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - a forward pagination is performed once the focused event has been rendered`() = runTest {
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(true)
+ }
+ val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
+ val presenter = createTimelinePresenter(
+ room = room,
+ timeline = room.liveTimeline,
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
+ consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
+ initialState.eventSink.invoke(TimelineEvent.OnFocusEventRender)
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda)
+ .isCalledOnce()
+ .with(value(Timeline.PaginationDirection.FORWARDS))
+ assertThat(expectMostRecentItem().isLive).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - a backward pagination while the focused event is not rendered yet is performed`() = runTest {
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(false)
+ }
+ val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
+ val presenter = createTimelinePresenter(
+ room = room,
+ timeline = room.liveTimeline,
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
+ consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda)
+ .isCalledOnce()
+ .with(value(Timeline.PaginationDirection.BACKWARDS))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - a forward pagination while the focus request is debounced is ignored`() = runTest {
+ val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
+ Result.success(true)
+ }
+ val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
+ val presenter = createTimelinePresenter(
+ room = room,
+ timeline = room.liveTimeline,
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = 1.seconds))
+ // The focus request has started and is now waiting for the debounce to elapse
+ consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Requested }
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda).isNeverCalled()
+ assertThat(expectMostRecentItem().isLive).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - a forward pagination while the focused timeline is loading is ignored`() = runTest {
+ val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
+ Result.success(true)
+ }
+ val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
+ val presenter = createTimelinePresenter(
+ room = room,
+ timeline = room.liveTimeline,
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
+ // The focused timeline is being created, the focus request has not resolved yet
+ consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Loading }
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda).isNeverCalled()
+ assertThat(expectMostRecentItem().isLive).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - a forward pagination after a failed focus request is performed`() = runTest {
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(false)
+ }
+ val room = aRoomWithDetachedTimeline(
+ paginateLambda = paginateLambda,
+ createTimelineResult = { Result.failure(RuntimeException("An error")) },
+ )
+ val presenter = createTimelinePresenter(
+ room = room,
+ timeline = room.liveTimeline,
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
+ consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Failure }
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda)
+ .isCalledOnce()
+ .with(value(Timeline.PaginationDirection.FORWARDS))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - a forward pagination in a thread timeline is ignored`() = runTest {
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(true)
+ }
+ val timeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
+ this.paginateLambda = paginateLambda
+ }
+ val presenter = createTimelinePresenter(timeline = timeline)
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda).isNeverCalled()
+ // A backward pagination is still performed in a thread timeline
+ initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
+ advanceUntilIdle()
+ assert(paginateLambda)
+ .isCalledOnce()
+ .with(value(Timeline.PaginationDirection.BACKWARDS))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun `present - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer()
@@ -1390,6 +1557,112 @@ class TimelinePresenterTest {
}
}
+ @Test
+ fun `present - show and hide send failure dialog`() = runTest {
+ val presenter = createTimelinePresenter()
+ val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error"))
+ presenter.test {
+ val initialState = awaitFirstItem()
+ assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
+ initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
+ awaitItem().also { state ->
+ assertThat(state.sendFailureDialogState).isEqualTo(
+ SendFailureDialogState.Show(
+ event = event,
+ sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"),
+ )
+ )
+ state.eventSink(TimelineEvent.HideSendFailureDialog)
+ }
+ awaitItem().also { state ->
+ assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
+ }
+ }
+ }
+
+ @Test
+ fun `present - show send failure dialog - unknown error with a blank reason`() {
+ assertSendFailureType(
+ sendState = LocalEventSendState.Failed.Unknown(error = ""),
+ expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown,
+ )
+ }
+
+ @Test
+ fun `present - show send failure dialog - invalid mime type`() {
+ assertSendFailureType(
+ sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"),
+ expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
+ )
+ }
+
+ @Test
+ fun `present - show send failure dialog - missing media content`() {
+ assertSendFailureType(
+ sendState = LocalEventSendState.Failed.MissingMediaContent,
+ expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
+ )
+ }
+
+ @Test
+ fun `present - show send failure dialog - sending from unverified device`() {
+ assertSendFailureType(
+ sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice,
+ expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
+ )
+ }
+
+ private fun assertSendFailureType(
+ sendState: LocalEventSendState.Failed,
+ expectedSendFailureType: SendFailureDialogState.SendFailureType,
+ ) = runTest {
+ val presenter = createTimelinePresenter()
+ val event = aMessageEvent(sendState = sendState)
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
+ assertThat(awaitItem().sendFailureDialogState).isEqualTo(
+ SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType)
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest {
+ val resolveEventSinkRecorder = lambdaRecorder { }
+ val presenter = createTimelinePresenter(
+ resolveVerifiedUserSendFailurePresenter = {
+ aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder)
+ },
+ )
+ val event = aMessageEvent(
+ sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
+ )
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
+ // The generic dialog is not displayed, the dedicated one is.
+ assertThat(consumeItemsUntilTimeout()).isEmpty()
+ assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
+ resolveEventSinkRecorder.assertions()
+ .isCalledOnce()
+ .with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event)))
+ }
+ }
+
+ @Test
+ fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest {
+ val presenter = createTimelinePresenter()
+ val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event)
+ presenter.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
+ assertThat(consumeItemsUntilTimeout()).isEmpty()
+ assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
+ }
+ }
+
@Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
val timeline = FakeTimeline(
@@ -1552,6 +1825,37 @@ class TimelinePresenterTest {
return awaitItem()
}
+ private fun aRoomWithDetachedTimeline(
+ paginateLambda: (Timeline.PaginationDirection) -> Result,
+ createTimelineResult: () -> Result = { Result.success(aDetachedTimeline(paginateLambda)) },
+ ): FakeJoinedRoom {
+ return FakeJoinedRoom(
+ liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())).apply {
+ this.paginateLambda = paginateLambda
+ },
+ createTimelineResult = { createTimelineResult() },
+ baseRoom = FakeBaseRoom(
+ roomPermissions = roomPermissions(),
+ threadRootIdForEventResult = { _ -> Result.success(null) },
+ ),
+ )
+ }
+
+ private fun aDetachedTimeline(
+ paginateLambda: (Timeline.PaginationDirection) -> Result,
+ ) = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(eventId = AN_EVENT_ID),
+ )
+ )
+ )
+ ).apply {
+ this.paginateLambda = paginateLambda
+ }
+
private fun roomPermissions(
canRedactOther: Boolean = false,
canRedactOwn: Boolean = true,
@@ -1589,6 +1893,7 @@ class TimelinePresenterTest {
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter = { aTimelineProtectionState() },
+ resolveVerifiedUserSendFailurePresenter: Presenter = { aResolveVerifiedUserSendFailureState() },
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -1602,7 +1907,7 @@ class TimelinePresenterTest {
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room, timeline),
- resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
+ resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
index f733f116d6..ac84d48c95 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@@ -178,6 +179,46 @@ class TimelineViewTest : RobolectricTest() {
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
}
+ @Test
+ fun `clicking on the send failure indicator emits the expected Event`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder()
+ val timelineItem = aTimelineItemEvent(
+ content = aTimelineItemTextContent(),
+ sendState = LocalEventSendState.Failed.Unknown("Error"),
+ )
+ setTimelineView(
+ state = aTimelineState(
+ timelineItems = persistentListOf(timelineItem),
+ eventSink = eventsRecorder,
+ ),
+ )
+ eventsRecorder.clear()
+
+ val contentDescription = activity!!.getString(CommonStrings.common_sending_failed)
+ onNodeWithContentDescription(contentDescription).performClick()
+ eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem))
+ }
+
+ @Test
+ fun `clicking on the send failure indicator of a verified user failure emits the expected Event`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder()
+ val timelineItem = aTimelineItemEvent(
+ content = aTimelineItemTextContent(),
+ sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
+ )
+ setTimelineView(
+ state = aTimelineState(
+ timelineItems = persistentListOf(timelineItem),
+ eventSink = eventsRecorder,
+ ),
+ )
+ eventsRecorder.clear()
+
+ val contentDescription = activity!!.getString(CommonStrings.common_sending_failed)
+ onNodeWithContentDescription(contentDescription).performClick()
+ eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem))
+ }
+
@Test
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt
index 094fa5764e..daf9625d2c 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt
@@ -13,9 +13,11 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
@@ -122,6 +124,50 @@ class TimelineItemsFactoryTest {
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
}
+ @Test
+ fun `a key verification request is not rendered`() = runTest {
+ val factory = aTimelineItemsFactory(
+ config = TimelineItemsFactoryConfig(
+ computeReadReceipts = false,
+ computeReactions = false,
+ )
+ )
+ val items = listOf(
+ MatrixTimelineItem.Event(
+ uniqueId = UniqueId("event-0"),
+ event = anEventTimelineItem(
+ sender = A_USER_ID,
+ content = aMessageContent(
+ body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.",
+ messageType = OtherMessageType(
+ msgType = "m.key.verification.request",
+ body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.",
+ ),
+ ),
+ ),
+ ),
+ MatrixTimelineItem.Event(
+ uniqueId = UniqueId("event-1"),
+ event = anEventTimelineItem(
+ sender = A_USER_ID,
+ content = aMessageContent(body = "A regular message"),
+ ),
+ ),
+ )
+ factory.timelineItems.test {
+ factory.replaceWith(
+ timelineItems = items,
+ roomMembers = emptyList(),
+ renderReadReceipts = false,
+ )
+ val bodies = awaitItem()
+ .filterIsInstance()
+ .map { (it.content as TimelineItemTextContent).body }
+ assertThat(bodies).containsExactly("A regular message")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
private suspend fun TestScope.groupPositionsOf(timestamps: List): List {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt
new file mode 100644
index 0000000000..05388e7227
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt
@@ -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.
+ */
+
+@file:OptIn(ExperimentalTestApi::class)
+
+package io.element.android.features.messages.impl.timeline.sendfailure
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
+import io.element.android.features.messages.impl.R
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import io.element.android.tests.testutils.setSafeContent
+import org.junit.Test
+
+class SendFailureDialogViewTest : RobolectricTest() {
+ @Test
+ fun `hidden state renders nothing`() = runAndroidComposeUiTest {
+ setSendFailureDialogView(
+ sendFailureDialogState = SendFailureDialogState.Hidden,
+ )
+ onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertDoesNotExist()
+ }
+
+ @Test
+ fun `show state renders the error failure reason`() = runAndroidComposeUiTest {
+ setSendFailureDialogView(
+ sendFailureDialogState = aSendFailureDialogStateShow(
+ sendFailureType = SendFailureDialogState.SendFailureType.Error("A failure reason"),
+ ),
+ )
+ assertNodeWithTextIsDisplayed(CommonStrings.common_sending_failed)
+ onNodeWithText(activity!!.getString(R.string.error_sending_failure_other, "A failure reason")).assertExists()
+ }
+
+ @Test
+ fun `show state renders the invalid mime type failure reason`() = runAndroidComposeUiTest {
+ setSendFailureDialogView(
+ sendFailureDialogState = aSendFailureDialogStateShow(
+ sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
+ ),
+ )
+ onNodeWithText(activity!!.getString(R.string.error_sending_failure_invalid_mime_type, "invalid/mimeType")).assertExists()
+ }
+
+ @Test
+ fun `show state renders the missing media content failure reason`() = runAndroidComposeUiTest {
+ setSendFailureDialogView(
+ sendFailureDialogState = aSendFailureDialogStateShow(
+ sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
+ ),
+ )
+ onNodeWithText(activity!!.getString(R.string.error_sending_failure_missing_media_content)).assertExists()
+ }
+
+ @Test
+ fun `show state renders the sending from unverified device failure reason`() = runAndroidComposeUiTest {
+ setSendFailureDialogView(
+ sendFailureDialogState = aSendFailureDialogStateShow(
+ sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
+ ),
+ )
+ onNodeWithText(activity!!.getString(R.string.error_sending_failure_sending_from_unverified_device)).assertExists()
+ }
+
+ @Test
+ fun `show state renders the unknown failure reason`() = runAndroidComposeUiTest {
+ setSendFailureDialogView(
+ sendFailureDialogState = aSendFailureDialogStateShow(
+ sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
+ ),
+ )
+ onNodeWithText(activity!!.getString(R.string.error_sending_failure_unknown)).assertExists()
+ }
+
+ @Test
+ fun `clicking on retry invokes the expected callback`() = runAndroidComposeUiTest {
+ val state = aSendFailureDialogStateShow()
+ ensureCalledOnceWithParam(state.event) { callback ->
+ setSendFailureDialogView(
+ sendFailureDialogState = state,
+ onRetry = callback,
+ )
+ clickOn(CommonStrings.action_retry)
+ }
+ }
+
+ @Test
+ fun `clicking on remove message invokes the expected callback`() = runAndroidComposeUiTest {
+ val state = aSendFailureDialogStateShow()
+ ensureCalledOnceWithParam(state.event) { callback ->
+ setSendFailureDialogView(
+ sendFailureDialogState = state,
+ onRemoveMessage = callback,
+ )
+ clickOn(CommonStrings.action_remove_message)
+ }
+ }
+
+ @Test
+ fun `clicking on cancel invokes the expected callback`() = runAndroidComposeUiTest {
+ ensureCalledOnce { callback ->
+ setSendFailureDialogView(
+ sendFailureDialogState = aSendFailureDialogStateShow(),
+ onDismiss = callback,
+ )
+ clickOn(CommonStrings.action_cancel)
+ }
+ }
+
+ private fun aSendFailureDialogStateShow(
+ sendFailureType: SendFailureDialogState.SendFailureType = SendFailureDialogState.SendFailureType.Unknown,
+ ) = SendFailureDialogState.Show(
+ event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("")),
+ sendFailureType = sendFailureType,
+ )
+
+ private fun AndroidComposeUiTest.setSendFailureDialogView(
+ sendFailureDialogState: SendFailureDialogState,
+ onDismiss: () -> Unit = EnsureNeverCalled(),
+ onRetry: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ onRemoveMessage: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ ) {
+ setSafeContent {
+ SendFailureDialogView(
+ sendFailureDialogState = sendFailureDialogState,
+ onDismiss = onDismiss,
+ onRetry = onRetry,
+ onRemoveMessage = onRemoveMessage,
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt
index 7d3b6cf647..fb44bb4e80 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt
@@ -60,6 +60,7 @@ import org.junit.Rule
import org.junit.Test
import java.io.File
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
@@ -67,8 +68,14 @@ class DefaultVoiceMessageComposerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
+ private val startRecordResult = lambdaRecorder { }
+ private val stopRecordResult = lambdaRecorder { }
+ private val deleteRecordingResult = lambdaRecorder { }
private val voiceRecorder = FakeVoiceRecorder(
- recordingDuration = RECORDING_DURATION
+ recordingDuration = RECORDING_DURATION,
+ startRecordResult = startRecordResult,
+ stopRecordResult = stopRecordResult,
+ deleteRecordingResult = deleteRecordingResult,
)
private val analyticsService = FakeAnalyticsService()
private val sendVoiceMessageResult =
@@ -97,7 +104,9 @@ class DefaultVoiceMessageComposerPresenterTest {
companion object {
private val RECORDING_DURATION = 1.seconds
+ private val FIRST_LEVEL_DURATION = 500.milliseconds
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList())
+ private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList())
}
@Test
@@ -106,7 +115,7 @@ class DefaultVoiceMessageComposerPresenterTest {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- voiceRecorder.assertCalls(started = 0)
+ startRecordResult.assertions().isNeverCalled()
testPauseAndDestroy(initialState)
}
@@ -118,9 +127,10 @@ class DefaultVoiceMessageComposerPresenterTest {
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
+ assertThat(awaitItem().voiceMessageState).isEqualTo(FIRST_RECORDING_STATE)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
- voiceRecorder.assertCalls(started = 1)
+ startRecordResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -133,13 +143,18 @@ class DefaultVoiceMessageComposerPresenterTest {
val voiceRecorder = FakeVoiceRecorder(
levels = levels,
recordingDuration = RECORDING_DURATION,
+ startRecordResult = { },
+ stopRecordResult = { },
+ deleteRecordingResult = { },
)
val presenter = createDefaultVoiceMessageComposerPresenter(
voiceRecorder = voiceRecorder,
)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
- skipItems(numberOfLevels / 2 - 1)
+
+ // Skip until we reach the final state, which should have the last 128 levels
+ skipItems(numberOfLevels - 1)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java)
val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording
@@ -221,7 +236,8 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
- voiceRecorder.assertCalls(started = 1, stopped = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
cancelAndIgnoreRemainingEvents()
}
@@ -235,7 +251,9 @@ class DefaultVoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(true))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
}
@@ -249,7 +267,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
@@ -265,8 +285,10 @@ class DefaultVoiceMessageComposerPresenterTest {
}
// Nothing should happen
- assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
- voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
+ assertThat(finalState.voiceMessageState).isEqualTo(FIRST_RECORDING_STATE)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isNeverCalled()
+ deleteRecordingResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
@@ -282,7 +304,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
}
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
@@ -299,7 +323,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
}
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
@@ -340,7 +366,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -360,7 +388,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -377,7 +407,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isCalledOnce()
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -455,7 +487,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isCalledOnce()
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -476,7 +510,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isCalledOnce()
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -499,7 +535,9 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).isEmpty()
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
@@ -527,7 +565,9 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isCalledOnce()
- voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
+ deleteRecordingResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -577,7 +617,7 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(1)
- voiceRecorder.assertCalls(started = 0)
+ startRecordResult.assertions().isNeverCalled()
testPauseAndDestroy(initialState)
}
@@ -586,8 +626,14 @@ class DefaultVoiceMessageComposerPresenterTest {
@Test
fun `present - record error - security exceptions are tracked`() = runTest {
val exception = SecurityException("")
- voiceRecorder.givenThrowsSecurityException(exception)
- val presenter = createDefaultVoiceMessageComposerPresenter()
+ val startRecordResult = lambdaRecorder { throw exception }
+ val voiceRecorder = FakeVoiceRecorder(
+ recordingDuration = RECORDING_DURATION,
+ startRecordResult = startRecordResult,
+ )
+ val presenter = createDefaultVoiceMessageComposerPresenter(
+ voiceRecorder = voiceRecorder,
+ )
presenter.test {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
@@ -596,7 +642,7 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(analyticsService.trackedErrors).containsExactly(
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
)
- voiceRecorder.assertCalls(started = 1)
+ startRecordResult.assertions().isCalledOnce()
testPauseAndDestroy(initialState)
}
@@ -616,7 +662,8 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
- voiceRecorder.assertCalls(stopped = 1)
+ startRecordResult.assertions().isNeverCalled()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
permissionsPresenter.setPermissionGranted()
@@ -625,7 +672,8 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = expectMostRecentItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
- voiceRecorder.assertCalls(stopped = 1, started = 1)
+ startRecordResult.assertions().isCalledOnce()
+ stopRecordResult.assertions().isCalledOnce().with(value(false))
testPauseAndDestroy(finalState)
}
@@ -659,7 +707,7 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = expectMostRecentItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
- voiceRecorder.assertCalls(started = 1)
+ startRecordResult.assertions().isCalledOnce()
testPauseAndDestroy(finalState)
}
@@ -696,7 +744,7 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue()
}
- voiceRecorder.assertCalls(started = 0)
+ startRecordResult.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
testPauseAndDestroy(finalState)
diff --git a/features/preferences/test/build.gradle.kts b/features/preferences/test/build.gradle.kts
index a066fe4707..5b8969796a 100644
--- a/features/preferences/test/build.gradle.kts
+++ b/features/preferences/test/build.gradle.kts
@@ -16,5 +16,7 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.features.preferences.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(libs.coroutines.core)
implementation(projects.tests.testutils)
}
diff --git a/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt
new file mode 100644
index 0000000000..9696a2050b
--- /dev/null
+++ b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.preferences.test
+
+import io.element.android.features.preferences.api.CacheService
+import io.element.android.libraries.matrix.api.core.SessionId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+
+class FakeCacheService(
+ override val clearedCacheEventFlow: Flow = emptyFlow(),
+) : CacheService
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
index 85731ae959..c7c706fa68 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
@@ -86,7 +86,7 @@ class BugReportPresenter(
fun handleEvent(event: BugReportEvents) {
when (event) {
BugReportEvents.SendBugReport -> {
- if (formState.value.description.length < 10) {
+ if (formState.value.description.length < 10 && formState.value.ghIssueNumber == null) {
sendingAction.value = AsyncAction.Failure(BugReportFormError.DescriptionTooShort)
} else {
sendingAction.value = AsyncAction.Loading
diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts
index afc5e51499..faad812d73 100644
--- a/features/rageshake/test/build.gradle.kts
+++ b/features/rageshake/test/build.gradle.kts
@@ -14,6 +14,7 @@ android {
}
dependencies {
+ implementation(projects.libraries.architecture)
implementation(projects.features.rageshake.api)
implementation(libs.coroutines.core)
implementation(projects.tests.testutils)
diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt
new file mode 100644
index 0000000000..d6e1a47368
--- /dev/null
+++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.rageshake.test
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeBugReportEntryPoint(
+ private val createNodeResult: (BuildContext, BugReportEntryPoint.Callback) -> Node = { _, _ -> lambdaError() },
+) : BugReportEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: BugReportEntryPoint.Callback,
+ ): Node = createNodeResult(buildContext, callback)
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
index 78be168e53..1ec0ce0691 100644
--- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
@@ -24,7 +24,10 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.TimeoutException
import kotlin.jvm.optionals.getOrElse
+import kotlin.time.Duration.Companion.seconds
@AssistedInject
class RoomAliasResolverPresenter(
@@ -61,9 +64,14 @@ class RoomAliasResolverPresenter(
private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch {
suspend {
- matrixClient.resolveRoomAlias(roomAlias)
+ val result = withTimeoutOrNull(ALIAS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) {
+ matrixClient.resolveRoomAlias(roomAlias)
+ } ?: throw TimeoutException("Timed out while resolving ${roomAlias.value}")
+ result
.getOrThrow()
.getOrElse { throw RoomAliasResolverFailures.UnknownAlias }
}.runCatchingUpdatingState(resolveState)
}
}
+
+private const val ALIAS_RESOLVE_TIMEOUT_IN_SECONDS = 10
diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt
index df8b8058be..a50049446b 100644
--- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt
+++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt
@@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SERVER_LIST
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.util.Optional
+import java.util.concurrent.TimeoutException
class RoomAliasHelperPresenterTest {
@get:Rule
@@ -60,6 +62,22 @@ class RoomAliasHelperPresenterTest {
}
}
+ @Test
+ fun `present - a server that never answers ends up in an error state`() = runTest {
+ val client = FakeMatrixClient(
+ resolveRoomAliasResult = { awaitCancellation() }
+ )
+ val presenter = createPresenter(matrixClient = client)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
+ assertThat(awaitItem().resolveState.isLoading()).isTrue()
+ val resultState = awaitItem()
+ assertThat(resultState.resolveState.errorOrNull()).isInstanceOf(TimeoutException::class.java)
+ }
+ }
+
@Test
fun `present - resolve alias error and retry`() = runTest {
val client = FakeMatrixClient(
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt
index cfa06c3a97..2ae31fb00c 100644
--- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt
@@ -42,7 +42,8 @@ class DefaultShareIntentHandler(
val uris = getIncomingUris(intent, type)
return when {
uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent)
- type.isMimeTypeImage() ||
+ uris.isNotEmpty() ||
+ type.isMimeTypeImage() ||
type.isMimeTypeVideo() ||
type.isMimeTypeAudio() ||
type.isMimeTypeApplication() ||
diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt
new file mode 100644
index 0000000000..d81bbe053d
--- /dev/null
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt
@@ -0,0 +1,303 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.share.impl
+
+import android.content.ComponentName
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.IntentFilter
+import android.database.Cursor
+import android.net.Uri
+import androidx.core.net.toUri
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import io.element.android.features.share.api.ShareIntentData
+import io.element.android.features.share.api.UriToShare
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import org.junit.Test
+import org.robolectric.Robolectric
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+
+class DefaultShareIntentHandlerTest : RobolectricTest() {
+ @Test
+ fun `an image is shared as an uri`() {
+ val uri = "content://sender/image.jpg".toUri()
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(
+ aSendIntent(type = "image/jpeg", uri = uri)
+ )
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = listOf(UriToShare(uri = uri, mimeType = "image/jpeg")),
+ )
+ )
+ }
+
+ @Test
+ fun `an uri with an unlisted mime type is shared`() {
+ val uri = "content://sender/logs.zip".toUri()
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(
+ aSendIntent(type = "message/rfc822", uri = uri)
+ )
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")),
+ )
+ )
+ }
+
+ @Test
+ fun `an uri with an unlisted mime type keeps the text as a caption`() {
+ val uri = "content://sender/logs.zip".toUri()
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(
+ aSendIntent(type = "message/rfc822", uri = uri).apply {
+ putExtra(Intent.EXTRA_TEXT, "a caption")
+ }
+ )
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = "a caption",
+ uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")),
+ )
+ )
+ }
+
+ @Test
+ fun `several uris with an unlisted mime type are shared`() {
+ val firstUri = "content://sender/first.zip".toUri()
+ val secondUri = "content://sender/second.zip".toUri()
+ val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ type = "message/rfc822"
+ putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(firstUri, secondUri))
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = listOf(
+ UriToShare(uri = firstUri, mimeType = "message/rfc822"),
+ UriToShare(uri = secondUri, mimeType = "message/rfc822"),
+ ),
+ )
+ )
+ }
+
+ @Test
+ fun `a plain text is shared as a text`() {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, "a text")
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isEqualTo(ShareIntentData.PlainText("a text"))
+ }
+
+ @Test
+ fun `an intent with neither uri nor text is not handled`() {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "message/rfc822"
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `an intent without mime type is not handled`() {
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(Intent(Intent.ACTION_SEND))
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `an intent with a listed mime type and no uri is handled`() {
+ listOf(
+ MimeTypes.Jpeg,
+ MimeTypes.Mp4,
+ MimeTypes.Mp3,
+ MimeTypes.Pdf,
+ "file/binary",
+ "text/html",
+ MimeTypes.Any,
+ ).forEach { type ->
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(
+ Intent(Intent.ACTION_SEND).apply { this.type = type }
+ )
+ assertWithMessage("$type was not properly handled").that(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = emptyList(),
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `a send multiple intent without uri is handled`() {
+ val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ type = MimeTypes.Jpeg
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = emptyList(),
+ )
+ )
+ }
+
+ @Test
+ fun `an uri is ignored when the intent is neither a send nor a send multiple`() {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ type = MimeTypes.Jpeg
+ putExtra(Intent.EXTRA_STREAM, "content://sender/image.jpg".toUri())
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = emptyList(),
+ )
+ )
+ }
+
+ @Test
+ fun `an empty text is not kept as a caption`() {
+ val uri = "content://sender/logs.zip".toUri()
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(
+ aSendIntent(type = "message/rfc822", uri = uri).apply {
+ putExtra(Intent.EXTRA_TEXT, "")
+ }
+ )
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")),
+ )
+ )
+ }
+
+ @Test
+ fun `a plain text intent without text is not handled`() {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = MimeTypes.PlainText
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `a plain text intent with an empty text is not handled`() {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = MimeTypes.PlainText
+ putExtra(Intent.EXTRA_TEXT, "")
+ }
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `the mime type of the uri overrides the one of the intent`() {
+ Robolectric.setupContentProvider(ZipContentProvider::class.java, "sender")
+ val uri = "content://sender/logs.zip".toUri()
+ val result = createDefaultShareIntentHandler().handleIncomingShareIntent(
+ aSendIntent(type = MimeTypes.Any, uri = uri)
+ )
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = listOf(UriToShare(uri = uri, mimeType = "application/zip")),
+ )
+ )
+ }
+
+ @Test
+ fun `the intent is made explicit for the application which will read the uri`() {
+ val intent = aSendIntent(type = "message/rfc822", uri = "content://sender/logs.zip".toUri())
+ givenAnApplicationHandling(intent)
+ createDefaultShareIntentHandler().handleIncomingShareIntent(intent)
+ assertThat(intent.action).isNull()
+ assertThat(intent.component).isEqualTo(ComponentName(A_PACKAGE_NAME, AN_ACTIVITY_NAME))
+ }
+
+ @Test
+ fun `the intent stays implicit when the uri permission cannot be granted`() {
+ val uri = "content://sender/logs.zip".toUri()
+ val intent = aSendIntent(type = "message/rfc822", uri = uri)
+ givenAnApplicationHandling(intent)
+ val result = DefaultShareIntentHandler(
+ context = FailingGrantUriPermissionContext(RuntimeEnvironment.getApplication()),
+ ).handleIncomingShareIntent(intent)
+ assertThat(intent.action).isEqualTo(Intent.ACTION_SEND)
+ assertThat(intent.component).isNull()
+ assertThat(result).isEqualTo(
+ ShareIntentData.Uris(
+ text = null,
+ uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")),
+ )
+ )
+ }
+
+ private fun aSendIntent(type: String, uri: Uri) = Intent(Intent.ACTION_SEND).apply {
+ this.type = type
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+
+ private fun givenAnApplicationHandling(intent: Intent) {
+ val componentName = ComponentName(A_PACKAGE_NAME, AN_ACTIVITY_NAME)
+ shadowOf(RuntimeEnvironment.getApplication().packageManager).apply {
+ addActivityIfNotPresent(componentName)
+ addIntentFilterForActivity(
+ componentName,
+ IntentFilter(intent.action).apply {
+ addCategory(Intent.CATEGORY_DEFAULT)
+ addDataType(intent.type)
+ }
+ )
+ }
+ }
+
+ private fun createDefaultShareIntentHandler() = DefaultShareIntentHandler(
+ context = RuntimeEnvironment.getApplication(),
+ )
+
+ private class FailingGrantUriPermissionContext(context: Context) : ContextWrapper(context) {
+ override fun grantUriPermission(toPackage: String, uri: Uri, modeFlags: Int) {
+ error("Unable to grant Uri permission")
+ }
+ }
+
+ private class ZipContentProvider : ContentProvider() {
+ override fun onCreate() = true
+
+ override fun getType(uri: Uri) = "application/zip"
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0
+ }
+
+ private companion object {
+ const val A_PACKAGE_NAME = "io.element.android.x"
+ const val AN_ACTIVITY_NAME = "io.element.android.x.MainActivity"
+ }
+}
diff --git a/features/signedout/test/build.gradle.kts b/features/signedout/test/build.gradle.kts
new file mode 100644
index 0000000000..5bbfb33c1a
--- /dev/null
+++ b/features/signedout/test/build.gradle.kts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.signedout.test"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.features.signedout.api)
+ implementation(projects.tests.testutils)
+}
diff --git a/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt
new file mode 100644
index 0000000000..863b946871
--- /dev/null
+++ b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.signedout.test
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.features.signedout.api.SignedOutEntryPoint
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeSignedOutEntryPoint(
+ private val createNodeResult: (BuildContext, SignedOutEntryPoint.Params) -> Node = { _, _ -> lambdaError() },
+) : SignedOutEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ params: SignedOutEntryPoint.Params,
+ ): Node = createNodeResult(buildContext, params)
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a5ebc5b4e7..95242ad038 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,12 +17,12 @@ constraintlayout = "2.2.2"
constraintlayout_compose = "1.1.2"
lifecycle = "2.11.0"
activity = "1.13.0"
-media3 = "1.10.1"
+media3 = "1.11.0"
camera = "1.6.1"
work = "2.11.2"
# Compose
-compose_bom = "2026.06.01"
+compose_bom = "2026.08.00"
# Coroutines
coroutines = "1.11.0"
@@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test
test_core = "1.7.0"
-roborazzi = "1.71.0"
+roborazzi = "1.72.0"
# Jetbrain
datetime = "0.8.0"
@@ -54,7 +54,7 @@ haze = "1.7.2"
dependencyAnalysis = "3.18.0"
# DI
-metro = "1.4.1"
+metro = "1.4.2"
# Auto service
autoservice = "1.1.1"
@@ -123,7 +123,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = "androidx.startup:startup-runtime:1.2.0"
androidx_preference = "androidx.preference:preference:1.2.1"
-androidx_webkit = "androidx.webkit:webkit:1.16.0"
+androidx_webkit = "androidx.webkit:webkit:1.17.0"
androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" }
androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' }
@@ -149,7 +149,7 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
squareup_seismic = "com.squareup:seismic:1.0.3"
# network
-network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.4.0"
+network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.5.0"
network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_okhttp = { module = "com.squareup.okhttp3:okhttp" }
@@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs.
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.17"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19"
# Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -215,7 +215,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
-maplibre = "org.maplibre.gl:android-sdk:13.4.1"
+maplibre = "org.maplibre.gl:android-sdk:13.5.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_compose = "org.maplibre.compose:maplibre-compose:0.14.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
@@ -230,7 +230,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics
posthog = "com.posthog:posthog-android:3.56.4"
-sentry = "io.sentry:sentry-android:8.52.0"
+sentry = "io.sentry:sentry-android:8.53.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0"
@@ -242,7 +242,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call
-element_call_embedded = "io.element.android:element-call-embedded:0.23.0"
+element_call_embedded = "io.element.android:element-call-embedded:0.24.0"
# Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 84c5a9c661..760c45969c 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
+distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
diff --git a/libraries/accountselect/test/build.gradle.kts b/libraries/accountselect/test/build.gradle.kts
new file mode 100644
index 0000000000..c38cd8ac69
--- /dev/null
+++ b/libraries/accountselect/test/build.gradle.kts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.libraries.accountselect.test"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.accountselect.api)
+ implementation(projects.tests.testutils)
+}
diff --git a/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt
new file mode 100644
index 0000000000..acc5ba10df
--- /dev/null
+++ b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.accountselect.test
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeAccountSelectEntryPoint(
+ private val createNodeResult: (BuildContext, AccountSelectEntryPoint.Callback) -> Node = { _, _ -> lambdaError() },
+) : AccountSelectEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: AccountSelectEntryPoint.Callback,
+ ): Node = createNodeResult(buildContext, callback)
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt
new file mode 100644
index 0000000000..b17ec0260b
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.androidutils.media
+
+import android.app.PictureInPictureParams
+import android.content.res.Configuration
+import android.os.Build
+import android.util.Rational
+import androidx.annotation.RequiresApi
+
+/**
+ * Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device.
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder {
+ val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ // If landscape orientation, invert the aspect ratio
+ Rational(5, 3)
+ } else {
+ // In any other case, assume portrait orientation
+ Rational(3, 5)
+ }
+ return setAspectRatio(aspectRatio)
+}
diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts
index 419e81962c..c0db3c4f86 100644
--- a/libraries/audio/impl/build.gradle.kts
+++ b/libraries/audio/impl/build.gradle.kts
@@ -1,4 +1,5 @@
import extension.setupDependencyInjection
+import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
@@ -22,4 +23,6 @@ dependencies {
implementation(libs.androidx.corektx)
implementation(projects.libraries.di)
+
+ testCommonDependencies(libs)
}
diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt
index bc6a42d2f9..a9bc6fdbfd 100644
--- a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt
+++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt
@@ -48,9 +48,8 @@ class DefaultAudioFocus(
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
- // For recording, ignore transient focus losses (e.g., notification sounds).
- // The AudioRecord API keeps capturing regardless.
- if (requester != AudioFocusRequester.RecordVoiceMessage) {
+ Timber.d("AudioFocus: transient loss ($it)")
+ if (requester.pausesOnTransientFocusLoss()) {
onFocusLost()
}
}
@@ -96,8 +95,8 @@ class DefaultAudioFocus(
private fun AudioFocusRequester.toAudioUsage(): Int {
return when (this) {
AudioFocusRequester.ElementCall,
- AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION
+ AudioFocusRequester.VoiceMessage,
AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA
}
}
@@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int {
private fun AudioFocusRequester.toAudioStream(): Int {
return when (this) {
AudioFocusRequester.ElementCall,
- AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL
+ AudioFocusRequester.VoiceMessage,
AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC
}
}
+private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean {
+ return when (this) {
+ // The AudioRecord API keeps capturing regardless.
+ AudioFocusRequester.RecordVoiceMessage,
+ AudioFocusRequester.VoiceMessage -> false
+ AudioFocusRequester.ElementCall,
+ AudioFocusRequester.MediaViewer -> true
+ }
+}
+
private fun AudioFocusRequester.willPausedWhenDucked(): Boolean {
return when (this) {
// (note that for Element Call, there is no action when the focus is lost)
diff --git a/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt
new file mode 100644
index 0000000000..d0e255dd41
--- /dev/null
+++ b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ * Copyright 2026 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.audio.impl
+
+import android.media.AudioAttributes
+import android.media.AudioManager
+import androidx.core.content.getSystemService
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.audio.api.AudioFocusRequester
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import org.junit.Test
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+
+class DefaultAudioFocusTest : RobolectricTest() {
+ @Test
+ fun `voice message playback requests focus for media, not for a call`() {
+ assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA)
+ }
+
+ @Test
+ fun `media viewer playback requests focus for media`() {
+ assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA)
+ }
+
+ @Test
+ fun `voice message recording requests focus for voice communication`() {
+ assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ }
+
+ @Test
+ fun `element call requests focus for voice communication`() {
+ assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ }
+
+ @Test
+ fun `a transient focus loss does not pause a voice message`() {
+ assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
+ assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse()
+ }
+
+ @Test
+ fun `a permanent focus loss pauses a voice message`() {
+ assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue()
+ }
+
+ @Test
+ fun `a transient focus loss does not stop a voice message recording`() {
+ assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
+ }
+
+ @Test
+ fun `a transient focus loss still pauses the media viewer`() {
+ assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue()
+ }
+
+ private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean {
+ val context = RuntimeEnvironment.getApplication()
+ val audioManager = requireNotNull(context.getSystemService())
+ var focusLost = false
+ DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true }
+ val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
+ request.listener.onAudioFocusChange(focusChange)
+ return focusLost
+ }
+
+ private fun usageOf(requester: AudioFocusRequester): Int {
+ val context = RuntimeEnvironment.getApplication()
+ val audioManager = requireNotNull(context.getSystemService())
+ DefaultAudioFocus(context).requestAudioFocus(requester) {}
+ val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
+ return requireNotNull(request.audioFocusRequest).audioAttributes.usage
+ }
+}
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt
index bf5932da59..e519984e9f 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt
@@ -8,8 +8,11 @@
package io.element.android.compound.theme
-import androidx.compose.foundation.isSystemInDarkTheme
+import android.content.res.Configuration
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -27,12 +30,28 @@ private fun Theme.coerceBlackTheme(allowBlackTheme: Boolean): Theme {
@Composable
fun Theme.isDark(): Boolean {
return when (this) {
- Theme.System -> isSystemInDarkTheme()
+ Theme.System -> isSystemThemeDark()
Theme.Dark, Theme.Black -> true
Theme.Light -> false
}
}
+/**
+ * Whether the system-wide theme is dark.
+ *
+ * This deliberately does not use [androidx.compose.foundation.isSystemInDarkTheme], which reads the activity configuration: `AppCompatDelegate` rewrites
+ * that configuration whenever a non-system theme is selected, so it keeps reporting the previously forced value. The application configuration is left
+ * untouched by AppCompat and is what AppCompat itself reads to expand `MODE_NIGHT_FOLLOW_SYSTEM`.
+ */
+@Composable
+private fun isSystemThemeDark(): Boolean {
+ val applicationContext = LocalContext.current.applicationContext
+ val activityConfiguration = LocalConfiguration.current
+ return remember(activityConfiguration) {
+ applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
+ }
+}
+
fun Flow.mapToTheme(allowBlackTheme: Boolean): Flow = map {
when (it) {
null -> Theme.System
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt
index 3f43cfea67..57ae9dd332 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt
@@ -11,19 +11,22 @@ package io.element.android.compound.theme
import android.content.res.Configuration
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
+import org.robolectric.RuntimeEnvironment
-class ThemeTest {
+class ThemeTest : RobolectricTest() {
@Test
fun `isDark for System dark returns true`() {
`isDark for System`(
- uiMode = Configuration.UI_MODE_NIGHT_YES,
+ systemUiMode = Configuration.UI_MODE_NIGHT_YES,
expected = true,
)
}
@@ -31,21 +34,38 @@ class ThemeTest {
@Test
fun `isDark for System light return false`() {
`isDark for System`(
- uiMode = Configuration.UI_MODE_NIGHT_NO,
+ systemUiMode = Configuration.UI_MODE_NIGHT_NO,
expected = false,
)
}
- fun `isDark for System`(
- uiMode: Int,
+ @Test
+ fun `isDark for System ignores the night mode AppCompat forced on the activity`() {
+ `isDark for System`(
+ systemUiMode = Configuration.UI_MODE_NIGHT_YES,
+ activityUiMode = Configuration.UI_MODE_NIGHT_NO,
+ expected = true,
+ )
+ `isDark for System`(
+ systemUiMode = Configuration.UI_MODE_NIGHT_NO,
+ activityUiMode = Configuration.UI_MODE_NIGHT_YES,
+ expected = false,
+ )
+ }
+
+ private fun `isDark for System`(
+ systemUiMode: Int,
+ activityUiMode: Int = systemUiMode,
expected: Boolean,
) = runTest {
+ val application = RuntimeEnvironment.getApplication()
+ application.resources.configuration.uiMode = systemUiMode
moleculeFlow(RecompositionMode.Immediate) {
var result: Boolean? = null
CompositionLocalProvider(
- // Let set the system to dark
+ LocalContext provides application,
LocalConfiguration provides Configuration().apply {
- this.uiMode = uiMode
+ this.uiMode = activityUiMode
},
) {
result = Theme.System.isDark()
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
index fe563138ad..87d6350c65 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.core.extensions
+import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@@ -114,3 +115,14 @@ fun String.toSafeLength(
this
}
}
+
+/**
+ * Remove accents and replace special characters by ASCII from the string.
+ * For instance, "é" will be replaced by "e", "ç" by "c", etc.
+ */
+fun String.normalized(): String {
+ // Normalize the string.
+ return Normalizer.normalize(this, Normalizer.Form.NFD)
+ // then keep only ASCII characters and replace non-ASCII characters with an empty string.
+ .replace("[^\\p{ASCII}]".toRegex(), "")
+}
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
index e7962f8bd6..3901a32059 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
@@ -63,4 +63,17 @@ object MimeTypes {
val subType = components.last()
return subType.isNotBlank() && subType != "*"
}
+
+ /**
+ * A mime type with a wildcard subtype is a filter, not a media type, so replace it with a
+ * concrete default for its top level type.
+ */
+ fun String?.ensureDefaultSubtype(): String = when {
+ this == null -> OctetStream
+ hasSubtype(this) -> this
+ this == Images -> Jpeg
+ this == Videos -> Mp4
+ this == Audio -> Mp3
+ else -> OctetStream
+ }
}
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt
index 666ee1d7ab..91402e3d11 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt
@@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean {
}
/**
- * Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty
+ * Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is
+ * not the case, "https://" is added, only if the String is not empty.
*/
fun String.ensureProtocol(): String {
return when {
isEmpty() -> this
- !startsWith("http") -> "https://$this"
+ !startsWith("http", ignoreCase = true) -> "https://$this"
else -> this
}
}
diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt
index 0099a67a79..c3cae14843 100644
--- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt
+++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt
@@ -74,4 +74,10 @@ class BasicExtensionsTest {
val result = textWithDirectionOverrides.filterDirectionOverrides()
assertEquals("123456789", result)
}
+
+ @Test
+ fun `normalized removes accents and replace special characters from text`() {
+ assertEquals("Stefan", "Ștefan".normalized())
+ assertEquals("ca enleve etre", "ça enlève être".normalized())
+ }
}
diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt
new file mode 100644
index 0000000000..f12b73b40b
--- /dev/null
+++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.core.mimetype
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype
+import org.junit.Test
+
+class MimeTypesTest {
+ @Test
+ fun `ensureDefaultSubtype keeps a mime type that already has a subtype`() {
+ assertThat(MimeTypes.Png.ensureDefaultSubtype()).isEqualTo(MimeTypes.Png)
+ assertThat(MimeTypes.Mp4.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4)
+ assertThat(MimeTypes.Pdf.ensureDefaultSubtype()).isEqualTo(MimeTypes.Pdf)
+ }
+
+ @Test
+ fun `ensureDefaultSubtype replaces a wildcard subtype with a concrete one`() {
+ assertThat(MimeTypes.Images.ensureDefaultSubtype()).isEqualTo(MimeTypes.Jpeg)
+ assertThat(MimeTypes.Videos.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4)
+ assertThat(MimeTypes.Audio.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp3)
+ }
+
+ @Test
+ fun `ensureDefaultSubtype falls back to octet stream`() {
+ assertThat(MimeTypes.Any.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream)
+ assertThat("text/*".ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream)
+ assertThat(null.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream)
+ }
+}
diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt
new file mode 100644
index 0000000000..9a9d68dcaa
--- /dev/null
+++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.core.uri
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class UrlUtilsTest {
+ @Test
+ fun `ensureProtocol keeps an empty string unchanged`() {
+ assertThat("".ensureProtocol()).isEqualTo("")
+ }
+
+ @Test
+ fun `ensureProtocol prepends https when there is no scheme`() {
+ assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
+ }
+
+ @Test
+ fun `ensureProtocol keeps an existing http or https scheme`() {
+ assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
+ assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org")
+ }
+
+ @Test
+ fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() {
+ assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org")
+ assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org")
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt
index ac7e426e65..99c45c67d9 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt
@@ -8,9 +8,11 @@
package io.element.android.libraries.designsystem.components.avatar
+import androidx.compose.runtime.Immutable
import io.element.android.libraries.core.data.tryOrNull
import java.text.BreakIterator
+@Immutable
data class AvatarData(
val id: String,
val name: String?,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
index eb27780073..b91f8c405c 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
@@ -8,9 +8,11 @@
package io.element.android.libraries.designsystem.components.avatar
+import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+@Immutable
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
CurrentRoomTopBar(32.dp),
@@ -18,6 +20,7 @@ enum class AvatarSize(val dp: Dp) {
IncomingCall(140.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
+ SearchRoomListItem(32.dp),
ThreadsListItem(52.dp),
SpaceListItem(52.dp),
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt
similarity index 63%
rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt
rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt
index a6fb234b86..611bf7bf18 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt
@@ -8,9 +8,19 @@
package io.element.android.libraries.matrix.api
/**
- * An interface to fetch content from a URL using a simple GET request.
+ * An interface to fetch content from a URL using a simple GET request launched from a Matrix client.
*/
-fun interface UrlContentFetcher {
+interface ClientUrlContentFetcher {
+ /**
+ * The domain of the public server for this homeserver.
+ */
+ val server: String?
+
+ /**
+ * The URL of the actual Matrix homeserver.
+ */
+ val homeserverUrl: String
+
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index b83d96af96..3f98dcc509 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -60,16 +60,13 @@ import java.util.Optional
* One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation.
* Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread.
*/
-interface MatrixClient : UrlContentFetcher {
+interface MatrixClient : ClientUrlContentFetcher {
/** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */
val sessionId: SessionId
/** Identifier of this device, as assigned by the homeserver when the session was created. */
val deviceId: DeviceId
- /** Base URL of the homeserver this client is connected to. */
- val homeserverUrl: String
-
/** Directories holding this session's databases, caches and downloaded media. */
val sessionPaths: SessionPaths
@@ -136,6 +133,9 @@ interface MatrixClient : UrlContentFetcher {
/** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
val contentScanner: ContentScanner?
+ /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */
+ val isShuttingDown: Boolean
+
/**
* Returns the room with the given id if the user has joined it, along with its live [Timeline].
* A new instance is created on each call and the caller owns it, so it must be closed once no longer needed.
@@ -245,6 +245,9 @@ interface MatrixClient : UrlContentFetcher {
/** Whether the homeserver advertises support for user status (MSC4426). */
suspend fun isUserStatusSupported(): Result
+ /** Whether the homeserver advertises support for the Profiles sliding sync extension (MSC4262). */
+ suspend fun isProfilesSlidingSyncExtensionSupported(): Result
+
/**
* Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call.
*
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt
new file mode 100644
index 0000000000..730561bbef
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api
+
+/**
+ * An abstraction over the Matrix SDK's `ClientBuilder` to allow sharing it with other modules without exposing the SDK directly.
+ */
+interface MatrixClientBuilder
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt
index 1e3ffea154..5013362f85 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt
@@ -15,4 +15,4 @@ import java.lang.AutoCloseable
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
* with it, such as temporary files.
*/
-interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
+interface TemporaryMatrixClient : ClientUrlContentFetcher, AutoCloseable
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt
index 3bd22c64f3..c3244b4742 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt
@@ -12,10 +12,10 @@ package io.element.android.libraries.matrix.api
*/
interface TemporaryMatrixClientFactory {
/**
- * Builds an unauthenticated client pointing at [homeServerUrl], backed by a fresh set of temporary session directories.
+ * Builds an unauthenticated client pointing at [serverName], backed by a fresh set of temporary session directories.
* The caller owns the returned client and must close it to delete those directories.
*
- * @param homeServerUrl the URL of the homeserver the client will talk to.
+ * @param serverName the domain of the public-facing server for the actual homeserver
*/
- suspend fun create(homeServerUrl: String): Result
+ suspend fun create(serverName: String): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt
deleted file mode 100644
index 2c11c750e4..0000000000
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.libraries.matrix.api.scanner
-
-import io.element.android.libraries.matrix.api.UrlContentFetcher
-import io.element.android.libraries.matrix.api.core.SessionId
-
-/**
- * Provides the URL of the content scanner service for a given homeserver, if any is set up.
- */
-fun interface ContentScannerUrlProvider {
- /**
- * Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up.
- */
- suspend fun getContentScannerUrl(sessionId: SessionId): Result
-
- /**
- * Creates a provider, which needs a fetcher because the scanner URL is itself discovered over HTTP.
- */
- fun interface Factory {
- /**
- * @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works.
- */
- fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider
- }
-}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt
index 2f61b8f58a..f2bd56ac57 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt
@@ -128,4 +128,11 @@ sealed interface GalleryItemType {
data class OtherMessageType(
val msgType: String,
val body: String,
-) : MessageType
+) : MessageType {
+ /**
+ * A `m.key.verification.request` carries a body meant for clients that cannot handle in-chat
+ * verification, so it must not be rendered as a message.
+ */
+ val isKeyVerificationRequest: Boolean
+ get() = msgType == "m.key.verification.request"
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 0ff563a204..a36184d6bd 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.Optional
+import java.util.concurrent.atomic.AtomicBoolean
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -145,7 +146,7 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
@Suppress("LargeClass")
class RustMatrixClient(
override val sessionPaths: SessionPaths,
- private val innerClient: Client,
+ val innerClient: Client,
private val sessionStore: SessionStore,
private val sessionDelegate: RustClientSessionDelegate,
private val innerSyncService: ClientSyncService,
@@ -162,6 +163,7 @@ class RustMatrixClient(
) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
+ override val server: String? = innerClient.server()
override val homeserverUrl: String = innerClient.homeserver()
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
@@ -304,6 +306,10 @@ class RustMatrixClient(
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
+ private val _isShuttingDown = AtomicBoolean(false)
+ override val isShuttingDown: Boolean
+ get() = _isShuttingDown.get()
+
init {
// Make sure the session delegate has a reference to the client to be able to logout on auth error
sessionDelegate.bindClient(this)
@@ -320,7 +326,9 @@ class RustMatrixClient(
}
private suspend fun setupUserProfile() {
- val supported = isUserStatusSupported().getOrDefault(false)
+ // Subscribing to own profile updates only requires the Profiles sliding sync extension,
+ // not the full user status capability (which also needs the status profile field to be settable).
+ val supported = isProfilesSlidingSyncExtensionSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
@@ -530,6 +538,12 @@ class RustMatrixClient(
}
}
+ override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ innerClient.isProfilesSlidingSyncExtensionSupported()
+ }
+ }
+
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
}
@@ -666,11 +680,13 @@ class RustMatrixClient(
}
override suspend fun clearCache() {
+ _isShuttingDown.set(true)
innerClient.clearCaches(innerSyncService)
destroy()
}
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
+ _isShuttingDown.set(true)
sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -708,6 +724,8 @@ class RustMatrixClient(
}
override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = withContext(sessionDispatcher) {
+ _isShuttingDown.set(true)
+
Timber.w("Deactivating account")
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt
new file mode 100644
index 0000000000..b2f952f35b
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl
+
+import io.element.android.libraries.matrix.api.MatrixClientBuilder
+import org.matrix.rustcomponents.sdk.ClientBuilder
+
+/**
+ * A wrapper around the Matrix SDK's [ClientBuilder] to allow sharing it with other modules without exposing the SDK directly.
+ */
+class RustMatrixClientBuilder(
+ val inner: ClientBuilder,
+) : MatrixClientBuilder
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index a0b06a2e1e..0d593626bb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl
import dev.zacsweers.metro.Inject
+import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.ByteUnit
@@ -19,7 +20,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.paths.SessionPaths
-import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
@@ -37,7 +37,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
-import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.Session
@@ -70,7 +69,7 @@ class RustMatrixClientFactory(
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
- private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory,
+ private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook,
) {
private val sessionDelegate = RustClientSessionDelegate(
sessionStore = sessionStore,
@@ -101,6 +100,7 @@ class RustMatrixClientFactory(
)
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
+ .let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
.use { it.build() }
client.setMediaRetentionPolicy(
@@ -135,21 +135,6 @@ class RustMatrixClientFactory(
client.setUtdDelegate(UtdTracker(analyticsService))
- // If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
- // This allows the SDK to use the content scanner for automatic media scanning.
- // If no content scanner URL is available, the contentScanner will be null.
- val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null))
- val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId()))
- .getOrNull()
- ?.let { contentScannerUrl ->
- val contentScanner = ContentScanner(contentScannerUrl)
- client.setContentScanner(contentScanner)
- RustContentScanner(
- client = client,
- rustScanner = contentScanner,
- )
- }
-
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
@@ -170,7 +155,7 @@ class RustMatrixClientFactory(
featureFlagService = featureFlagService,
analyticsService = analyticsService,
workManagerScheduler = workManagerScheduler,
- contentScanner = contentScanner,
+ contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) },
isMessageSearchAvailable = isMessageSearchAvailable,
).also {
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt
index 54e4f696d3..f5b1f2ba4e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt
@@ -22,6 +22,9 @@ class RustTemporaryMatrixClient(
client.getUrl(url)
}.mapFailure { it.mapClientException() }
+ override val server: String? = client.server()
+ override val homeserverUrl: String = client.homeserver()
+
override fun close() {
client.close()
paths?.let {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt
index 70903ba91a..0e7d8bf2c5 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt
@@ -19,7 +19,7 @@ class RustTemporaryMatrixClientFactory(
private val sessionPathsFactory: SessionPathsFactory,
private val rustMatrixClientFactory: RustMatrixClientFactory,
) : TemporaryMatrixClientFactory {
- override suspend fun create(homeServerUrl: String): Result {
+ override suspend fun create(serverName: String): Result {
return runCatchingExceptions {
val sessionPaths = sessionPathsFactory.create()
val client = rustMatrixClientFactory.getBaseClientBuilder(
@@ -28,7 +28,7 @@ class RustTemporaryMatrixClientFactory(
slidingSyncType = ClientBuilderSlidingSync.Native,
isMessageSearchAvailable = false,
)
- .homeserverUrl(homeServerUrl)
+ .serverName(serverName)
.build()
RustTemporaryMatrixClient(client, sessionPaths)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 2c936c9b23..a1dacbee91 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.auth
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
+import io.element.android.features.enterprise.api.ClientEnterpriseHook
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -73,6 +74,7 @@ class RustMatrixAuthenticationService(
private val oAuthConfigurationProvider: OAuthConfigurationProvider,
private val enterpriseService: EnterpriseService,
private val featureFlagService: FeatureFlagService,
+ private val clientEnterpriseHook: ClientEnterpriseHook,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
@@ -166,6 +168,10 @@ class RustMatrixAuthenticationService(
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
+
+ // Apply enterprise hooks to the newly created client as soon as possible
+ clientEnterpriseHook(matrixClient)
+
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@@ -242,6 +248,9 @@ class RustMatrixAuthenticationService(
client.restoreSession(sessionData.toSession())
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
+ // Apply enterprise hooks to the newly created client as soon as possible
+ clientEnterpriseHook(matrixClient)
+
// We wait for the verification state to be known
matrixClient.waitForKnownVerificationState()
@@ -278,7 +287,6 @@ class RustMatrixAuthenticationService(
.let {
enterpriseService.tweakMasUrl(
url = it,
- homeserver = client.server() ?: client.homeserver(),
urlContentFetcher = getUrlResolver,
)
}
@@ -333,6 +341,10 @@ class RustMatrixAuthenticationService(
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
+
+ // Apply enterprise hooks to the newly created client as soon as possible
+ clientEnterpriseHook(matrixClient)
+
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
@@ -398,6 +410,10 @@ class RustMatrixAuthenticationService(
sessionPaths = emptySessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
+
+ // Apply enterprise hooks to the newly created client as soon as possible
+ clientEnterpriseHook(matrixClient)
+
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
index 46fa885d25..f6ea8019e3 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
@@ -13,9 +13,9 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
+import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -111,7 +112,12 @@ object SessionMatrixModule {
}
@Provides
- fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher {
+ fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher {
return matrixClient
}
+
+ @Provides
+ fun providesMessageSearchService(matrixClient: MatrixClient): MessageSearchService {
+ return matrixClient.messageSearchService
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
index d1401d2624..7b3a1d9f10 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
@@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
-import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -71,15 +71,8 @@ class RustMediaLoader(
val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource,
filename = filename,
- mimeType = when {
- mimeType == null -> MimeTypes.OctetStream
- MimeTypes.hasSubtype(mimeType) -> mimeType
- // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension.
- mimeType == MimeTypes.Images -> MimeTypes.Jpeg
- mimeType == MimeTypes.Videos -> MimeTypes.Mp4
- mimeType == MimeTypes.Audio -> MimeTypes.Mp3
- else -> MimeTypes.OctetStream
- },
+ // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension.
+ mimeType = mimeType.ensureDefaultSubtype(),
useCache = useCache,
tempDir = cacheDirectory.path,
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
index 5e5000aedc..6d3afad5c3 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
@@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
@@ -52,7 +51,6 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
- contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -67,5 +65,5 @@ fun TestScope.createRustMatrixClientFactory(
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
- contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
+ clientBuilderEnterpriseHook = { builder, _ -> builder },
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt
index a8843a9ca2..d0b87ef53a 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt
@@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
-import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
@@ -64,7 +63,6 @@ class RustTemporaryMatrixClientFactoryTest {
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
- contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -79,6 +77,6 @@ class RustTemporaryMatrixClientFactoryTest {
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
- contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
+ clientBuilderEnterpriseHook = { builder, _ -> builder },
)
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt
index 322103042b..23e0cf2302 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt
@@ -16,7 +16,6 @@ import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
-import org.matrix.rustcomponents.sdk.NoHandle
import java.io.File
import kotlin.io.path.createTempDirectory
import org.matrix.rustcomponents.sdk.ClientException as RustClientException
@@ -84,7 +83,7 @@ class RustTemporaryMatrixClientTest {
}
private fun createRustTemporaryMatrixClient(
- client: Client = Client(NoHandle),
+ client: Client = FakeFfiClient(),
paths: SessionPaths,
): RustTemporaryMatrixClient {
return RustTemporaryMatrixClient(client, paths)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
index e349c616c0..5d66e4f4bd 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
@@ -98,6 +98,7 @@ class RustMatrixAuthenticationServiceTest {
),
enterpriseService = enterpriseService,
featureFlagService = FakeFeatureFlagService(),
+ clientEnterpriseHook = {},
)
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
index 13be3cf2d5..86587da6c1 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
@@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
+import io.element.android.libraries.matrix.impl.scanner.FakeFfiContentScanner
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
+import io.element.android.libraries.matrix.test.A_SERVER_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
+import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
@@ -45,6 +48,7 @@ class FakeFfiClient(
private val userId: String = A_USER_ID.value,
private val deviceId: String = A_DEVICE_ID.value,
private val homeserver: String = A_HOMESERVER_URL,
+ private val server: String? = A_SERVER_NAME,
private val notificationClient: NotificationClient = FakeFfiNotificationClient(),
private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(),
private val encryption: Encryption = FakeFfiEncryption(),
@@ -59,13 +63,16 @@ class FakeFfiClient(
private val accountDataResult: (String) -> String? = { lambdaError() },
private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val isUserStatusSupportedResult: () -> Boolean = { false },
+ private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
+ private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
override fun deviceId(): String = deviceId
override fun homeserver(): String = homeserver
+ override fun server(): String? = server
override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient
override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings
override fun encryption(): Encryption = encryption
@@ -100,6 +107,8 @@ class FakeFfiClient(
override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult()
+ override suspend fun isProfilesSlidingSyncExtensionSupported(): Boolean = isProfilesSlidingSyncExtensionSupportedResult()
+
override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle {
subscribeToOwnProfileResult(listener)
return FakeFfiTaskHandle()
@@ -139,5 +148,9 @@ class FakeFfiClient(
getUrlResult(url)
}
+ override suspend fun contentScanner(): ContentScanner = simulateLongTask {
+ contentScannerResult()
+ }
+
override fun close() = closeResult()
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt
new file mode 100644
index 0000000000..2018228985
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.test
+
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeClientUrlContentFetcher(
+ override val server: String = A_SERVER_NAME,
+ override val homeserverUrl: String = A_HOMESERVER_URL,
+ private val getUrlResult: (String) -> Result = { lambdaError() },
+) : ClientUrlContentFetcher {
+ override suspend fun getUrl(url: String): Result {
+ return getUrlResult(url)
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index 56d25ed175..6ba42270fe 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -79,6 +79,7 @@ class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
override val deviceId: DeviceId = A_DEVICE_ID,
+ override val server: String = A_SERVER_NAME,
override val homeserverUrl: String = A_HOMESERVER_URL,
override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: String? = A_USER_NAME,
@@ -99,7 +100,7 @@ class FakeMatrixClient(
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() },
- private val resolveRoomAliasResult: (RoomAlias) -> Result> = {
+ private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = {
Result.success(
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
)
@@ -133,6 +134,7 @@ class FakeMatrixClient(
private val resetWellKnownConfigLambda: () -> Result = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null,
+ private val isShuttingDownResult: () -> Boolean = { false },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -147,6 +149,7 @@ class FakeMatrixClient(
var setUserStatusResult: Result = Result.success(Unit)
var clearUserStatusResult: Result = Result.success(Unit)
var isUserStatusSupportedResult: Result = Result.success(true)
+ var isProfilesSlidingSyncExtensionSupportedResult: Result = Result.success(true)
private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow = _userProfile
@@ -293,6 +296,8 @@ class FakeMatrixClient(
override suspend fun isUserStatusSupported(): Result = isUserStatusSupportedResult
+ override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = isProfilesSlidingSyncExtensionSupportedResult
+
override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled)
override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId)
@@ -305,6 +310,9 @@ class FakeMatrixClient(
return knockRoomLambda(roomIdOrAlias, message, serverNames)
}
+ override val isShuttingDown: Boolean
+ get() = isShuttingDownResult()
+
// Mocks
fun givenCreateRoomResult(result: Result) {
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt
index 83bde4653a..3ee7fb5f2f 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt
@@ -11,10 +11,11 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
class FakeTemporaryMatrixClient(
+ override val server: String = A_SERVER_NAME,
+ override val homeserverUrl: String = A_HOMESERVER_URL,
private val getUrlResult: (String) -> Result = { lambdaError() },
private val closeLambda: () -> Unit = {},
) : TemporaryMatrixClient {
override suspend fun getUrl(url: String): Result = getUrlResult(url)
-
override fun close() = closeLambda()
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt
index cdec68bdd1..e906777dcb 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt
@@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
class FakeTemporaryMatrixClientFactory(
private val createResult: (String) -> Result = { Result.success(FakeTemporaryMatrixClient()) },
) : TemporaryMatrixClientFactory {
- override suspend fun create(homeServerUrl: String): Result = createResult(homeServerUrl)
+ override suspend fun create(serverName: String): Result = createResult(serverName)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index c41ce0fe3e..c8f49eaf56 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -72,6 +72,7 @@ const val A_SPACE_NAME = "A space name"
const val A_REDACTION_REASON = "A redaction reason"
+const val A_SERVER_NAME = "matrix.org"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt
index 3b2fdeafb1..1eac6c0a1d 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt
@@ -70,10 +70,15 @@ class FakeMatrixAuthenticationService(
return importCreatedSessionLambda(externalSession)
}
+ /** The login hint passed to the most recent [getOAuthUrl] call. */
+ var getOAuthUrlLoginHint: String? = null
+ private set
+
override suspend fun getOAuthUrl(
prompt: OAuthPrompt,
loginHint: String?,
): Result = simulateLongTask {
+ getOAuthUrlLoginHint = loginHint
oAuthError?.let { Result.failure(it) } ?: Result.success(AN_OAUTH_DATA)
}
diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt
index fa478edde7..36c5208603 100644
--- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt
+++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
@@ -82,16 +83,17 @@ class AndroidMediaPreProcessor(
mediaOptimizationConfig: MediaOptimizationConfig,
): Result = withContext(coroutineDispatchers.computation) {
runCatchingExceptions {
+ val resolvedMimeType = mimeType.ensureDefaultSubtype()
val result = when {
- mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
- mimeType.isMimeTypeImage() -> {
- val imageMimeType = resolveImageMimeType(uri, mimeType)
+ resolvedMimeType == MimeTypes.Svg -> processSvgImage(uri, resolvedMimeType)
+ resolvedMimeType.isMimeTypeImage() -> {
+ val imageMimeType = resolveImageMimeType(uri, mimeType).ensureDefaultSubtype()
val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes
processImage(uri, imageMimeType, shouldBeCompressed)
}
- mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
- mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
- else -> processFile(uri, mimeType)
+ resolvedMimeType.isMimeTypeVideo() -> processVideo(uri, resolvedMimeType, mediaOptimizationConfig.videoCompressionPreset)
+ resolvedMimeType.isMimeTypeAudio() -> processAudio(uri, resolvedMimeType)
+ else -> processFile(uri, resolvedMimeType)
}
if (deleteOriginal) {
tryOrNull {
diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt
index 9ea3921258..090cf11674 100644
--- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt
+++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt
@@ -65,6 +65,20 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(height).isEqualTo(2268)
}
+ @Test
+ fun `test processing an image shared with a wildcard mime type`() = runTest {
+ val mediaUploadInfo = process(
+ asset = assetImageJpeg,
+ mimeType = MimeTypes.Images,
+ mediaOptimizationConfig = MediaOptimizationConfig(
+ compressImages = true,
+ videoCompressionPreset = VideoCompressionPreset.STANDARD,
+ ),
+ )
+ val info = mediaUploadInfo as MediaUploadInfo.Image
+ assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg)
+ }
+
private suspend fun TestScope.process(
asset: Asset,
mediaOptimizationConfig: MediaOptimizationConfig,
@@ -124,7 +138,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
}
@Test
- fun `a file that is not an image keeps the wildcard mime type it was shared with`() = runTest {
+ fun `a file that is not an image falls back to the default subtype of the wildcard it was shared with`() = runTest {
val mediaUploadInfo = process(
asset = assetText,
mimeType = MimeTypes.Images,
@@ -135,7 +149,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
- assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Images)
+ assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg)
}
@Test
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
index 4da8433cd1..e23cb790db 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
@@ -266,4 +266,5 @@ class ExoPlayerForPreview(
override fun setVideoCodecParameters(codecParameters: CodecParameters) {}
override fun addVideoCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List) {}
override fun removeVideoCodecParametersChangeListener(listener: CodecParametersChangeListener) {}
+ override fun setEnforceAdPlaybackOnTimelineRefresh(enforceAdPlaybackOnTimelineRefresh: Boolean) = throw NotImplementedError()
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
index e53153ce19..77222d97ff 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
@@ -92,7 +92,7 @@ class MediaViewerNode(
is MediaViewerEntryPoint.Params.RoomMedia -> {
val eventId = inputs.eventId
if (eventId == null) {
- // Should not happen
+ // Local echo, the viewer will not be focused on the item
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt
index 3ecd091e0d..2a32c6d273 100644
--- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt
+++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt
@@ -12,6 +12,7 @@ import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.core.extensions.normalized
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.SdkMetadata
@@ -26,11 +27,11 @@ class DefaultUserAgentProvider(
override fun provide(): String = userAgent
/**
- * Create an user agent with the application version.
+ * Create a user agent with the application version.
* Ex: Element X/1.5.0 (Xiaomi Mi 9T; Android 11; RKQ1.200826.002; Sdk c344b155c)
*/
private fun buildUserAgent(): String {
- val appName = buildMeta.applicationName
+ val appName = buildMeta.applicationName.normalized()
val appVersion = buildMeta.versionName
val deviceManufacturer = Build.MANUFACTURER
val deviceModel = Build.MODEL
diff --git a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt
similarity index 94%
rename from libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt
rename to libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt
index 5a5ca4369e..49abe58622 100644
--- a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt
+++ b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.oauth.test.customtab
+package io.element.android.libraries.oauth.test
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
index f35fa88442..6285c46389 100644
--- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
+++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
@@ -84,6 +84,20 @@ interface AppPreferencesStore {
/** The configured log level; defaults to the one the build was compiled with. */
fun getTracingLogLevelFlow(): Flow
+ /**
+ * The list of account providers (homeserver URLs) the user has previously authenticated
+ * against, most recent first. Used to default the account provider and to power autocomplete
+ * suggestions during sign-in. Stored locally only; never synced across devices.
+ */
+ fun getHomeserverHistoryFlow(): Flow>
+
+ /**
+ * Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]).
+ * The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are
+ * moved to the front rather than duplicated, and the list is capped in size. No-op for blanks.
+ */
+ suspend fun addHomeserverToHistory(url: String)
+
/**
* @param targets the extra log packs to enable on top of the default targets.
*/
diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
index af35ac3c44..1a14a0ea8f 100644
--- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
+++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.preferences.impl.store
+import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
@@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
+private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory")
private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri")
private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion")
private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName")
@@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU
private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion")
private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName")
+// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String.
+private const val HOMESERVER_HISTORY_DELIMITER = "\n"
+private const val MAX_HOMESERVER_HISTORY_SIZE = 20
+
@ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore(
private val buildMeta: BuildMeta,
@@ -168,6 +174,20 @@ class DefaultAppPreferencesStore(
}
}
+ override fun getHomeserverHistoryFlow(): Flow> {
+ return store.data.map { prefs -> prefs.readHomeserverHistory() }
+ }
+
+ override suspend fun addHomeserverToHistory(url: String) {
+ val normalized = url.trim().lowercase()
+ if (normalized.isEmpty()) return
+ store.edit { prefs ->
+ val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized })
+ .take(MAX_HOMESERVER_HISTORY_SIZE)
+ prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER)
+ }
+ }
+
override fun getMessageSoundFlow(): Flow {
return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) }
}
@@ -242,6 +262,13 @@ class DefaultAppPreferencesStore(
}
}
+private fun Preferences.readHomeserverHistory(): List {
+ return this[homeserverHistoryKey]
+ ?.split(HOMESERVER_HISTORY_DELIMITER)
+ ?.filter { it.isNotEmpty() }
+ ?: emptyList()
+}
+
private fun BuildMeta.defaultLogLevel(): LogLevel {
return when (buildType) {
BuildType.DEBUG -> LogLevel.TRACE
diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt
index c52d1648ac..4cf018371a 100644
--- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt
+++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt
@@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest {
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
}
+
+ @Test
+ fun `homeserver history is empty by default`() = runTest {
+ val store = createStore()
+ assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
+ }
+
+ @Test
+ fun `adding homeservers keeps the most recent first`() = runTest {
+ val store = createStore()
+
+ store.addHomeserverToHistory("https://matrix.org")
+ store.addHomeserverToHistory("https://example.com")
+
+ assertThat(store.getHomeserverHistoryFlow().first())
+ .containsExactly("https://example.com", "https://matrix.org")
+ .inOrder()
+ }
+
+ @Test
+ fun `adding an existing homeserver moves it to the front without duplicating`() = runTest {
+ val store = createStore()
+
+ store.addHomeserverToHistory("https://matrix.org")
+ store.addHomeserverToHistory("https://example.com")
+ store.addHomeserverToHistory("https://matrix.org")
+
+ assertThat(store.getHomeserverHistoryFlow().first())
+ .containsExactly("https://matrix.org", "https://example.com")
+ .inOrder()
+ }
+
+ @Test
+ fun `homeservers are normalised and deduplicated case-insensitively`() = runTest {
+ val store = createStore()
+
+ store.addHomeserverToHistory(" HTTPS://Matrix.ORG ")
+ store.addHomeserverToHistory("https://matrix.org")
+
+ assertThat(store.getHomeserverHistoryFlow().first())
+ .containsExactly("https://matrix.org")
+ }
+
+ @Test
+ fun `blank homeservers are ignored`() = runTest {
+ val store = createStore()
+
+ store.addHomeserverToHistory(" ")
+
+ assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
+ }
+
+ @Test
+ fun `homeserver history is capped to the most recent entries`() = runTest {
+ val store = createStore()
+
+ repeat(25) { index ->
+ store.addHomeserverToHistory("https://server$index.org")
+ }
+
+ val history = store.getHomeserverHistoryFlow().first()
+ assertThat(history).hasSize(20)
+ assertThat(history.first()).isEqualTo("https://server24.org")
+ assertThat(history.last()).isEqualTo("https://server5.org")
+ }
+
+ private fun createStore() = DefaultAppPreferencesStore(
+ buildMeta = buildMeta,
+ preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
+ )
}
diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt
index 6499e70223..e8d0b0bb8e 100644
--- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt
+++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore(
@@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO,
traceLogPacks: Set = emptySet(),
+ homeserverHistory: List = emptyList(),
messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundChannelVersion: Int = 0,
messageSoundDisplayName: String? = null,
@@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore(
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLogPacks)
+ private val homeserverHistory = MutableStateFlow(homeserverHistory)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val messageSound = MutableStateFlow(messageSound)
@@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore(
return tracingLogPacks
}
+ override fun getHomeserverHistoryFlow(): Flow> {
+ return homeserverHistory
+ }
+
+ override suspend fun addHomeserverToHistory(url: String) {
+ val normalized = url.trim().lowercase()
+ if (normalized.isEmpty()) return
+ homeserverHistory.update { current ->
+ (listOf(normalized) + current.filter { it != normalized }).take(10)
+ }
+ }
+
override fun getMessageSoundFlow(): Flow {
return messageSound
}
diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts
index b9082c50f6..8615485bba 100644
--- a/libraries/push/impl/build.gradle.kts
+++ b/libraries/push/impl/build.gradle.kts
@@ -1,3 +1,5 @@
+import config.BuildTimeConfig
+import extension.buildConfigFieldStr
import extension.setupDependencyInjection
import extension.testCommonDependencies
@@ -17,6 +19,33 @@ plugins {
android {
namespace = "io.element.android.libraries.push.impl"
+ buildFeatures {
+ buildConfig = true
+ }
+
+ buildTypes {
+ val defaultPusherAppId = "im.vector.app.android"
+ getByName("release") {
+ buildConfigFieldStr(
+ name = "PUSHER_APP_ID",
+ value = BuildTimeConfig.PUSHER_APP_ID_RELEASE ?: defaultPusherAppId,
+ )
+ }
+ getByName("debug") {
+ buildConfigFieldStr(
+ name = "PUSHER_APP_ID",
+ value = BuildTimeConfig.PUSHER_APP_ID_DEBUG ?: defaultPusherAppId,
+ )
+ }
+ register("nightly") {
+ matchingFallbacks += listOf("release")
+ buildConfigFieldStr(
+ name = "PUSHER_APP_ID",
+ value = BuildTimeConfig.PUSHER_APP_ID_NIGHTLY ?: defaultPusherAppId,
+ )
+ }
+ }
+
testOptions {
unitTests {
isIncludeAndroidResources = true
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt
index 35482aa164..7aa519a791 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt
@@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
-import io.element.android.appconfig.PushConfig
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
@@ -74,7 +73,7 @@ class DefaultPusherSubscriber(
): SetHttpPusherData =
SetHttpPusherData(
pushKey = pushKey,
- appId = PushConfig.PUSHER_APP_ID,
+ appId = BuildConfig.PUSHER_APP_ID,
// TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())
profileTag = DEFAULT_PUSHER_FILE_TAG + "_",
// TODO localeProvider.current().language
@@ -104,7 +103,7 @@ class DefaultPusherSubscriber(
.unsetHttpPusher(
unsetHttpPusherData = UnsetHttpPusherData(
pushKey = pushKey,
- appId = PushConfig.PUSHER_APP_ID
+ appId = BuildConfig.PUSHER_APP_ID
)
)
.onSuccess {
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt
index 4ed8ca8338..6921c050db 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt
@@ -10,9 +10,9 @@ package io.element.android.libraries.push.impl.test
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
-import io.element.android.appconfig.PushConfig
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.push.impl.BuildConfig
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
import io.element.android.libraries.pushproviders.api.Config
@@ -28,7 +28,7 @@ class DefaultTestPush(
pushGatewayNotifyRequest.execute(
PushGatewayNotifyRequest.Params(
url = config.url,
- appId = PushConfig.PUSHER_APP_ID,
+ appId = BuildConfig.PUSHER_APP_ID,
pushKey = config.pushKey,
eventId = TEST_EVENT_ID,
roomId = TEST_ROOM_ID,
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt
index 8cb1e2a7f6..76479720be 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt
@@ -9,7 +9,6 @@
package io.element.android.libraries.push.impl
import com.google.common.truth.Truth.assertThat
-import io.element.android.appconfig.PushConfig
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
@@ -96,7 +95,7 @@ class DefaultPusherSubscriberTest {
value(
SetHttpPusherData(
pushKey = "aPushKey",
- appId = PushConfig.PUSHER_APP_ID,
+ appId = BuildConfig.PUSHER_APP_ID,
url = "aGateway",
appDisplayName = "MyApp",
deviceDisplayName = "MyDevice",
@@ -163,7 +162,7 @@ class DefaultPusherSubscriberTest {
value(
UnsetHttpPusherData(
pushKey = "aPushKey",
- appId = PushConfig.PUSHER_APP_ID,
+ appId = BuildConfig.PUSHER_APP_ID,
),
)
)
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt
index 9ab0f16b62..3722c09d99 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt
@@ -8,7 +8,7 @@
package io.element.android.libraries.push.impl.test
-import io.element.android.appconfig.PushConfig
+import io.element.android.libraries.push.impl.BuildConfig
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
import io.element.android.libraries.pushproviders.test.aSessionPushConfig
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -33,7 +33,7 @@ class DefaultTestPushTest {
value(
PushGatewayNotifyRequest.Params(
url = aConfig.url,
- appId = PushConfig.PUSHER_APP_ID,
+ appId = BuildConfig.PUSHER_APP_ID,
pushKey = aConfig.pushKey,
eventId = DefaultTestPush.TEST_EVENT_ID,
roomId = DefaultTestPush.TEST_ROOM_ID,
diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md
index a2a7ad97ed..d9732af0d0 100644
--- a/libraries/pushproviders/firebase/README.md
+++ b/libraries/pushproviders/firebase/README.md
@@ -2,6 +2,6 @@
## Configuration
-In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
+In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
-To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `build.gradle.kts` files.
+To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `firebase.xml` files.
diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts
index 1a8e73a4da..33e88dd94c 100644
--- a/libraries/pushproviders/firebase/build.gradle.kts
+++ b/libraries/pushproviders/firebase/build.gradle.kts
@@ -8,7 +8,6 @@
@file:Suppress("UnstableApiUsage")
-import config.BuildTimeConfig
import extension.setupDependencyInjection
import extension.testCommonDependencies
import org.gradle.kotlin.dsl.withType
@@ -28,27 +27,10 @@ android {
buildTypes {
getByName("release") {
consumerProguardFiles("consumer-proguard-rules.pro")
- resValue(
- type = "string",
- name = "google_app_id",
- value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE,
- )
- }
- getByName("debug") {
- resValue(
- type = "string",
- name = "google_app_id",
- value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG,
- )
}
register("nightly") {
consumerProguardFiles("consumer-proguard-rules.pro")
matchingFallbacks += listOf("release")
- resValue(
- type = "string",
- name = "google_app_id",
- value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY,
- )
}
}
}
diff --git a/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml
new file mode 100644
index 0000000000..e96f163603
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml
@@ -0,0 +1,4 @@
+
+
+ 1:912726360885:android:def0a4e454042e9b00427c
+
diff --git a/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml
new file mode 100644
index 0000000000..7275ff2ddd
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml
@@ -0,0 +1,4 @@
+
+
+ 1:912726360885:android:e17435e0beb0303000427c
+
diff --git a/libraries/pushproviders/firebase/src/release/res/values/firebase.xml b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml
new file mode 100644
index 0000000000..3a9a4330d1
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml
@@ -0,0 +1,4 @@
+
+
+ 1:912726360885:android:d097de99a4c23d2700427c
+
diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
index 66c9dcba03..980443621c 100644
--- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
+++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
@@ -245,6 +245,20 @@ fun TextComposer(
canSendTextMessage,
) {
when {
+ composerMode.isEditing -> EndButtonParams(
+ endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
+ endButtonClick = {
+ if (canSendTextMessage) {
+ onSendMessage()
+ }
+ },
+ endButtonContent = @Composable {
+ SendButtonIcon(
+ canSendMessage = canSendTextMessage,
+ isEditing = true,
+ )
+ },
+ )
!canSendTextMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle -> EndButtonParams(
@@ -296,18 +310,6 @@ fun TextComposer(
)
}
}
- composerMode.isEditing -> EndButtonParams(
- endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
- endButtonClick = {
- onSendMessage()
- },
- endButtonContent = @Composable {
- SendButtonIcon(
- canSendMessage = true,
- isEditing = true,
- )
- },
- )
else -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt
new file mode 100644
index 0000000000..c979e8c909
--- /dev/null
+++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ * Copyright 2026 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalTestApi::class)
+
+package io.element.android.libraries.textcomposer
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.VoiceMessageState
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import io.element.android.wysiwyg.display.TextDisplay
+import org.junit.Test
+
+class TextComposerEndButtonTest : RobolectricTest() {
+ @Test
+ fun `editing a message with text shows the send edited message button`() = runAndroidComposeUiTest {
+ setTextComposer(text = "Some text", composerMode = aMessageComposerModeEdit())
+ onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed()
+ }
+
+ @Test
+ fun `emptying the composer while editing keeps the send edited message button`() = runAndroidComposeUiTest {
+ setTextComposer(text = "", composerMode = aMessageComposerModeEdit())
+ onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed()
+ onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist()
+ }
+
+ @Test
+ fun `emptying the composer while editing a caption keeps the send edited message button`() = runAndroidComposeUiTest {
+ setTextComposer(text = "", composerMode = aMessageComposerModeEditCaption(content = "An existing caption"))
+ onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed()
+ onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist()
+ }
+
+ @Test
+ fun `an empty composer outside of edition still offers to record a voice message`() = runAndroidComposeUiTest {
+ setTextComposer(text = "", composerMode = MessageComposerMode.Normal)
+ onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertIsDisplayed()
+ }
+
+ private fun AndroidComposeUiTest.getString(resId: Int): String {
+ return activity!!.getString(resId)
+ }
+
+ private fun AndroidComposeUiTest.setTextComposer(
+ text: String,
+ composerMode: MessageComposerMode,
+ ) {
+ setContent {
+ TextComposer(
+ state = aTextEditorStateMarkdown(initialText = text, initialFocus = true),
+ voiceMessageState = VoiceMessageState.Idle,
+ composerMode = composerMode,
+ onRequestFocus = {},
+ onSendMessage = {},
+ onResetComposerMode = {},
+ onAddAttachment = {},
+ onDismissTextFormatting = {},
+ onVoiceRecorderEvent = {},
+ onVoicePlayerEvent = {},
+ onSendVoiceMessage = {},
+ onDeleteVoiceMessage = {},
+ onError = {},
+ onTyping = {},
+ onReceiveSuggestion = {},
+ onSelectRichContent = null,
+ resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
+ resolveAtRoomMentionDisplay = { TextDisplay.Plain },
+ )
+ }
+ }
+}
diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml
index 223b8aa457..8a28af1e9c 100644
--- a/libraries/ui-strings/src/main/res/values-es/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-es/translations.xml
@@ -516,14 +516,6 @@ Motivo: %1$s."
"%1$s Android"
"Agitar con fuerza para informar de un error"
"Captura de pantalla"
- "Iniciar sesión"
- "Crear cuenta"
- "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)."
- "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)."
- "Proveedor de cuentas"
- "ejemplo.com"
- "Introduce tu proveedor de cuenta"
- "Elegir proveedor de cuentas"
"%1$s: %2$s"
"Opciones"
"Eliminar %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml
index 14800b9ca3..c24e8652f6 100644
--- a/libraries/ui-strings/src/main/res/values-et/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-et/translations.xml
@@ -507,14 +507,6 @@ Kas sa oled kindel, et soovid jätkata?"
"%1$s Android"
"Veast teatamiseks raputa nutiseadet ägedalt"
"Ekraanitõmmis"
- "Logi sisse"
- "Loo kasutajakonto"
- "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)."
- "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)."
- "Teenusepakkuja"
- "domeeninäidis.ee"
- "Sisesta oma kasutajakonto teenusepakkuja"
- "Vali kasutajakonto teenusepakkuja"
"%1$s: %2$s"
"Valikud"
"Kustuta: %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml
index b1cc4889a8..11ca38edf0 100644
--- a/libraries/ui-strings/src/main/res/values-fi/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml
@@ -516,14 +516,6 @@ Haluatko varmasti jatkaa?"
"%1$s Android"
"Raivostunut ravistaminen ilmoittaa virheestä"
"Näyttökuva"
- "Kirjaudu sisään"
- "Luo tili"
- "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)."
- "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)."
- "Palveluntarjoaja"
- "example.com"
- "Anna palveluntarjoajasi"
- "Valitse palveluntarjoaja"
"%1$s: %2$s"
"Vaihtoehdot"
"Poista %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml
index 105ced1ad5..6aced99b00 100644
--- a/libraries/ui-strings/src/main/res/values-fr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml
@@ -516,14 +516,6 @@ Raison : %1$s."
"%1$s Android"
"Rageshake pour signaler un problème"
"Capture d’écran"
- "Se connecter"
- "Créer un compte"
- "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)."
- "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)."
- "Fournisseur de compte"
- "exemple.com"
- "Saisissez votre fournisseur de compte"
- "Choisissez un fournisseur de compte"
"%1$s: %2$s"
"Options"
"Supprimer %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml
index 1ee4275703..f0ac7785da 100644
--- a/libraries/ui-strings/src/main/res/values-pl/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml
@@ -528,14 +528,6 @@ Czy na pewno chcesz kontynuować?"
"%1$s Android"
"Wstrząśnij gniewnie, aby zgłosić błąd"
"Zrzut ekranu"
- "Zaloguj"
- "Utwórz konto"
- "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)"
- "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)."
- "Dostawca konta"
- "przykład.com"
- "Wprowadź dostawcę swojego konta"
- "Wybierz dostawcę konta"
"%1$s: %2$s"
"Opcje"
"Usuń %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml
index 71b5ab7041..cd18373e6a 100644
--- a/libraries/ui-strings/src/main/res/values-ro/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml
@@ -528,14 +528,6 @@ Sunteți sigur că doriți să continuați?"
"%1$s Android"
"Rageshake pentru a raporta erori"
"Captură de ecran"
- "Autentificare"
- "Creați un cont"
- "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)."
- "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)."
- "Furnizor de cont"
- "exemplu.ro"
- "Introduceți furnizorul contului dumneavoastră"
- "Alegeți un furnizor de cont"
"%1$s: %2$s"
"Opțiuni"
"Ștergeți %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml
index 8545325603..e00a269473 100644
--- a/libraries/ui-strings/src/main/res/values-uk/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml
@@ -528,14 +528,6 @@
"%1$s Android"
"Повідомляти про помилку потрусивши телефон"
"Знімок екрана"
- "Увійти"
- "Створити обліковий запис"
- "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)."
- "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)."
- "Постачальник облікового запису"
- "example.com"
- "Вкажіть постачальника вашого облікового запису"
- "Оберіть постачальника облікового запису"
"%1$s: %2$s"
"Варіанти"
"Вилучити %1$s"
diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml
index b363ba9498..03929c0182 100644
--- a/libraries/ui-strings/src/main/res/values-zh/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml
@@ -504,14 +504,6 @@
"%1$s Android"
"摇一摇以报告 bug"
"屏幕截图"
- "登录"
- "创建账户"
- "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。"
- "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。"
- "账户提供者"
- "example.com"
- "输入你的账户提供者"
- "选择账户提供者"
"%1$s:%2$s"
"选项"
"移除 %1$s"
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 0d27ff5a9f..92899ba9da 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -516,14 +516,6 @@ Are you sure you want to continue?"
"%1$s Android"
"Rageshake to report bug"
"Screenshot"
- "Sign in"
- "Create account"
- "If your account is provided by your company or a community, then change the account provider (example: companyname.com)."
- "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."
- "Account provider"
- "example.com"
- "Enter your account provider"
- "Choose an account provider"
"%1$s: %2$s"
"Options"
"Remove %1$s"
diff --git a/libraries/voicerecorder/test/build.gradle.kts b/libraries/voicerecorder/test/build.gradle.kts
index c0f74b71e8..f54ef0e8d3 100644
--- a/libraries/voicerecorder/test/build.gradle.kts
+++ b/libraries/voicerecorder/test/build.gradle.kts
@@ -19,6 +19,5 @@ dependencies {
implementation(projects.tests.testutils)
implementation(libs.coroutines.test)
- implementation(libs.test.truth)
implementation(projects.libraries.core)
}
diff --git a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt
index ec62227857..b735bb9fa5 100644
--- a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt
+++ b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt
@@ -8,13 +8,13 @@
package io.element.android.libraries.voicerecorder.test
-import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.yield
import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -23,51 +23,47 @@ import kotlin.time.TestTimeSource
class FakeVoiceRecorder(
private val timeSource: TestTimeSource = TestTimeSource(),
private val recordingDuration: Duration = 0.seconds,
- private val levels: List = listOf(0.1f, 0.2f)
+ private val levels: List = listOf(0.1f, 0.2f),
+ val waveform: List = A_WAVEFORM,
+ private val startRecordResult: () -> Unit = { lambdaError() },
+ private val stopRecordResult: (Boolean) -> Unit = { lambdaError() },
+ private val deleteRecordingResult: () -> Unit = { lambdaError() },
) : VoiceRecorder {
private val _state = MutableStateFlow(VoiceRecorderState.Idle)
override val state: StateFlow = _state
- private var curRecording: File? = null
+ private val levelInterval = if (levels.isEmpty()) Duration.ZERO else recordingDuration / levels.size
- private var securityException: SecurityException? = null
+ private var currentRecording: File? = null
+ private var lastRecordingId = 0
+ private var activeRecordingId = 0
- private var startedCount = 0
- private var stoppedCount = 0
- private var deletedCount = 0
-
- var waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f)
override suspend fun startRecord() {
- startedCount += 1
+ startRecordResult()
+ if (activeRecordingId != 0) return
+ val recordingId = ++lastRecordingId
+ activeRecordingId = recordingId
+ currentRecording = File("file.ogg")
val startedAt = timeSource.markNow()
- securityException?.let { throw it }
-
- if (curRecording != null) {
- error("Previous recording was not cleared")
- }
- curRecording = File("file.ogg")
-
- timeSource += recordingDuration
for (i in 1..levels.size) {
+ delay(levelInterval)
+ if (activeRecordingId != recordingId) return
+ timeSource += levelInterval
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i)))
- yield()
}
}
- override suspend fun stopRecord(
- cancelled: Boolean
- ) {
- stoppedCount++
-
+ override suspend fun stopRecord(cancelled: Boolean) {
+ stopRecordResult(cancelled)
+ activeRecordingId = 0
if (cancelled) {
deleteRecording()
}
-
_state.emit(
- when (curRecording) {
+ when (val file = currentRecording) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(
- file = curRecording!!,
+ file = file,
mimeType = MimeTypes.Ogg,
duration = recordingDuration,
waveform = waveform,
@@ -77,25 +73,11 @@ class FakeVoiceRecorder(
}
override suspend fun deleteRecording() {
- deletedCount++
- curRecording = null
-
- _state.emit(
- VoiceRecorderState.Idle
- )
- }
-
- fun assertCalls(
- started: Int = 0,
- stopped: Int = 0,
- deleted: Int = 0,
- ) {
- assertThat(startedCount).isEqualTo(started)
- assertThat(stoppedCount).isEqualTo(stopped)
- assertThat(deletedCount).isEqualTo(deleted)
- }
-
- fun givenThrowsSecurityException(exception: SecurityException) {
- this.securityException = exception
+ deleteRecordingResult()
+ activeRecordingId = 0
+ currentRecording = null
+ _state.emit(VoiceRecorderState.Idle)
}
}
+
+private val A_WAVEFORM = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f)
diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt
index 1c77bea7cf..89ad4a63fc 100644
--- a/plugins/src/main/kotlin/Versions.kt
+++ b/plugins/src/main/kotlin/Versions.kt
@@ -45,7 +45,7 @@ private const val versionMonth = 8
* Release number in the month. Value must be in [0,99].
* Do not update this value. it is updated by the release script.
*/
-private const val versionReleaseNumber = 1
+private const val versionReleaseNumber = 2
object Versions {
/**
diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt
index 83236f1a5a..4160efeb78 100644
--- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt
+++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt
@@ -11,18 +11,14 @@ package config
object BuildTimeConfig {
const val APPLICATION_ID = "io.element.android.x"
const val APPLICATION_NAME = "Element X"
- const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c"
- const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c"
- const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c"
-
val METADATA_HOST_REVERSED: String? = null
+ val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
val URL_WEBSITE: String? = null
val URL_LOGO: String? = null
val URL_COPYRIGHT: String? = null
val URL_ACCEPTABLE_USE: String? = null
val URL_PRIVACY: String? = null
val URL_POLICY: String? = null
- val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
val SERVICES_MAPTILER_BASE_URL: String? = null
val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
@@ -33,7 +29,9 @@ object BuildTimeConfig {
val SERVICES_SENTRY_DSN_RUST: String? = null
val BUG_REPORT_URL: String? = null
val BUG_REPORT_APP_NAME: String? = null
-
- const val PUSH_CONFIG_INCLUDE_FIREBASE = true
- const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH = true
+ const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true
+ const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true
+ val PUSHER_APP_ID_RELEASE: String? = null
+ val PUSHER_APP_ID_DEBUG: String? = null
+ val PUSHER_APP_ID_NIGHTLY: String? = null
}
diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt
index 93b3200acc..76d0dfd582 100644
--- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt
+++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt
@@ -104,6 +104,7 @@ class KonsistClassNameTest {
"FakeImageLoader",
"FakeKeyStore",
"FakeListenableFuture",
+ "FakeParentNode",
)
.assertTrue {
val interfaceName = it.name
diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt
new file mode 100644
index 0000000000..cee2c74960
--- /dev/null
+++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.tests.testutils.node
+
+import com.bumble.appyx.core.node.Node
+import io.element.android.libraries.architecture.AssistedNodeFactory
+import io.element.android.libraries.architecture.NodeFactoriesBindings
+import kotlin.reflect.KClass
+
+class FakeNodeFactoriesBindings(
+ private val nodeFactories: Map, AssistedNodeFactory<*>>,
+) : NodeFactoriesBindings {
+ override fun nodeFactories() = nodeFactories
+}
diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt
new file mode 100644
index 0000000000..1375043d48
--- /dev/null
+++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.tests.testutils.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.ParentNode
+import com.bumble.appyx.core.node.node
+import io.element.android.libraries.di.DependencyInjectionGraphOwner
+
+/**
+ * A Node creates its children using the dependency injection graph of its ancestors, so a Node under test
+ * needs such a parent to be able to create its children.
+ */
+class FakeParentNode(
+ override val graph: Any,
+) : ParentNode(
+ navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null),
+ buildContext = BuildContext.root(savedStateMap = null),
+),
+ DependencyInjectionGraphOwner {
+ override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {}
+
+ @Composable
+ override fun View(modifier: Modifier) = Unit
+}
diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt
similarity index 93%
rename from tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt
rename to tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt
index ae8768e0b0..13fcc920c2 100644
--- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt
+++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.tests.testutils
+package io.element.android.tests.testutils.presenter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt
new file mode 100644
index 0000000000..bd786c2484
--- /dev/null
+++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.tests.testutils.presenter
+
+import androidx.compose.runtime.Composable
+import io.element.android.libraries.architecture.Presenter
+
+/**
+ * A presenter that cannot be used, util for testing Nodes.
+ */
+class NotUsedPresenter : Presenter {
+ @Composable
+ override fun present(): T = error("Not used")
+}
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png
new file mode 100644
index 0000000000..bd73e322ab
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
+size 3657
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png
new file mode 100644
index 0000000000..955af7bb9a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a856467fba0134aefcad2428493ce9169fccf90705b71d540c2eed935d9b625e
+size 22639
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png
new file mode 100644
index 0000000000..a4fa915963
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:70386b8f6a8998f246819c6d643543babcf41ba72db86db40559a755876c7650
+size 12144
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png
new file mode 100644
index 0000000000..25a66543da
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:18498dbff28491dd108900453eab13d8224aa7960eefbe25993dcc82198677dd
+size 35825
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png
new file mode 100644
index 0000000000..be7992706c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:346946f668bf18908480bf6b97158adcd7d9099d4be111055412ac2d4b7094c9
+size 77330
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png
new file mode 100644
index 0000000000..ca2936923c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cc3946915978e17cf404469273a5ff174c12f53da8fc3cff53ac6df8721e0182
+size 23522
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png
new file mode 100644
index 0000000000..bd73e322ab
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
+size 3657
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png
new file mode 100644
index 0000000000..cfef0a6883
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ad20c730bb320093a5c639938430e6851244e4425b46f3ae10262d9ca96215a4
+size 21972
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png
new file mode 100644
index 0000000000..ec806ea229
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cb20125a38d4d9436bee64de8f8d09d4afa372a72b7e33325b8d69f5b5760cdd
+size 11694
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png
new file mode 100644
index 0000000000..8dc241988a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a7d01b051fda7a3f6dc7736bdbd631bd2e0f0cef61dbd9c973b48cee1e69d56c
+size 35624
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png
new file mode 100644
index 0000000000..8e618f0070
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6637c79e684bf350204756a93679163fb17b8cf5f54c835fe81464d644edc3ff
+size 75840
diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png
new file mode 100644
index 0000000000..2864a9ffc8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e305c212a008854c19255f91824631d55d9fb523799ecad9b50039aa8b178323
+size 23053
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png
deleted file mode 100644
index c9bf9e3c1c..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0990301d92d4862054ba5e54103dac359df37997242498766854d7bb13431657
-size 6068
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png
deleted file mode 100644
index fb829c4873..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3022b2b813d9a3f77b0ba72706f0fb870a1c3f7b4a8efc90e611b66b6e9bdf8e
-size 5926
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png
deleted file mode 100644
index 23858ce575..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c7544e2566517e8692a1cccd4607bc2ab0f2767bbe9e5b39eb439d7695ad2ce5
-size 46905
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png
deleted file mode 100644
index 9b505f3cf9..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b5dbf45467305e83f302fd626a2c0adcd27eac6aeea8b7a83efdb6e728d974da
-size 44703
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png
deleted file mode 100644
index 815986cbc3..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:41907de78c86fca804af2af7dd351db6a1ab87515e71bb906ffa2219ad4f5879
-size 45880
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png
deleted file mode 100644
index f08662715a..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:62581b532695d10a42432dfcb37249c28c2bd617ecb49d7f3a8224855b78ae55
-size 43600
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png
index 721b1f8389..caa1dc1cbb 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9fb394bf8c063e5b4457fc75c171849b1ae7f435941e0d86d09298a846595861
-size 35160
+oid sha256:da11677aad93fcf9461c62a0fb9a1f384950dc69f0666914da5785265d56a4dd
+size 35877
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png
index 3d04fae696..cb6f870735 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4626f30754044cee4438c44f8972f1b643342aea9c7b38f3650874da8c7e7614
-size 37303
+oid sha256:99d28d3547d50e9ae23b012521ef5f8a157aa4b3a8a98b3e138847652f8cacff
+size 35761
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png
index c0d9a71d1d..d9eaefae3c 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ccaed5beab96b32c7ba783136d1fe8483697de44d0ed7c7555cbef9dd3708e66
-size 38953
+oid sha256:84954d900742e981811f1471fbe175f57c927ffc4da9e801d5e5b48f073dc13a
+size 39610
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png
new file mode 100644
index 0000000000..aa987474a3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a524b0538ad4204ea66c46c139035671f55d874c45d46476fb885c15879b93f2
+size 31806
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png
index 0127885711..0b0b5f123c 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5ae87930bb1123d432f7f4e2792ad1e372ec383f968ad335b4f2f069b8faa307
-size 34294
+oid sha256:bb5607ed2e0986d5035120a2e6276ff33e65a7bafa5325a0c0c22f1975b69c2b
+size 34986
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png
index b15dcf0142..4da08d87a3 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:787b3708bfc0bebd19cde31589e175ee0ea593c4bd1461b9593e9705947e6ffc
-size 36280
+oid sha256:80c7e798346755382f913f72da9b18fc200867280e6f33adde3622a964f7d2d7
+size 34883
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png
index b9046df90d..3d6c836738 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e8d1a1013e5eaed74889193e4c6551ae5afa96a1d10fa3d32de3dc96a0c1ae13
-size 36582
+oid sha256:cbe35c9bb9ec509b64a2ce27afa879b8904269ae8f9afbd5ac8a91098d7fccb3
+size 38777
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png
new file mode 100644
index 0000000000..bada75332c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:efa31bac320466c41fbf33b462418716c1f4fb6def584b38834320d213d2c819
+size 29777
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png
deleted file mode 100644
index 34cf030cba..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:09bf68ea40f2b1c109b015b8e9e9c767931ef70936a956652ac649b8e5a2561e
-size 13491
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png
deleted file mode 100644
index 67186daea6..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:323218a1ce6e4afaf4632fed871cbc6e17ed6686243523aeb9da0d81a7825d31
-size 13602
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png
deleted file mode 100644
index c0cc08611c..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:193e7af1adc71d5e1133694e234671ebb6fefe3766c9891733c3ae46a999b1cd
-size 13655
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png
deleted file mode 100644
index 26e32e2174..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:cb76a05a00c3e41c7edaa853db6d3773fddfb23c750eea4f45d4f4b1acfe5447
-size 15466
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png
deleted file mode 100644
index da53968272..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3a153cb8df9aadaa9cee0fef7f140e43f33851f18815674edc9a9682a7c601d2
-size 12942
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png
deleted file mode 100644
index 5df3ef308b..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:cba9108df3dae3f9968825e13528087fdf5d8b70c03faabbf0b06e276392a7d7
-size 13048
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png
deleted file mode 100644
index 91a5e6418a..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:79cd361b36c8be418871a8cf6773bcc8f3626fc8d09012340f6d245ea6b0574e
-size 12967
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png
deleted file mode 100644
index c6b38f80b0..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9063e4d7b900c397dacd964c25ce42865e292ea2a5a41f37cb7dfabd86f9246f
-size 14142
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png
deleted file mode 100644
index 86e3a6a0c1..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:64ca7f84a90416cac5161e379079edb3edadf1f82240214974a3216cd5d41090
-size 23418
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png
deleted file mode 100644
index 583a7beb59..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bd5189b1079f9011162ff7ba2c9211fbfff543db892b86ea3b57ec78495d2019
-size 50344
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png
deleted file mode 100644
index fd9fce9c5c..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5122ee1af4ff4ddc5e4c766f992e9833e4c1329bc634e82b0e52743326328447
-size 22757
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png
deleted file mode 100644
index 1be9bd5d99..0000000000
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:96742102f57f2e58027e3402ea01c91ee1eab6010dc46ef8ff8ee3175264a628
-size 49259
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png
new file mode 100644
index 0000000000..bd73e322ab
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
+size 3657
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png
new file mode 100644
index 0000000000..98a271f16e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3412f2c40e8bff9f152ad856050415a16b76376d46eda582168f75e126485676
+size 21803
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png
new file mode 100644
index 0000000000..de396af500
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ec927c9bd1b1e0000521d476f3141a6d2912b558ee1fddd089936afd59ac4d27
+size 20905
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png
new file mode 100644
index 0000000000..3edc968290
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:346138ce103462468652d51f969514a17b096795163ce1d2be2a99adf61471f4
+size 21766
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png
new file mode 100644
index 0000000000..74989de70a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1afe321e7c1f2a57d3317e952975655f89a4acae1386b425be089e03cc03a8d9
+size 22697
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png
new file mode 100644
index 0000000000..7bd3c0d04f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aba767b7b1380ca1fcd49498376128b5e537da7f15b2512c6f6cf409b3a3f9df
+size 19567
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png
new file mode 100644
index 0000000000..bd73e322ab
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
+size 3657
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png
new file mode 100644
index 0000000000..b1f589b037
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:05211670ecfed13426dae281438c0f51d4d98b1d33178ac1e3753bc36855b952
+size 20362
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png
new file mode 100644
index 0000000000..c2ff59b834
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9417e640c72190934981c3d7f29e9b60b241c69bcab800af7908b6b5d097089a
+size 19453
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png
new file mode 100644
index 0000000000..7aa92967f6
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e38f946dfda1008370b1171f4daffb2afea93b6f8ba9d8e816b8a12124467087
+size 20365
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png
new file mode 100644
index 0000000000..c5e55379a8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3996b007ecb0d6ccbba7b21d35a089b5ea5f0a314fe8a09b9f88e3829272a715
+size 21290
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png
new file mode 100644
index 0000000000..5270ee0b32
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0d7b95deec3bb0c5b542885a8900144233434561b5488e50c1560cdb287b9a61
+size 18154
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png
index f47e74ab7b..3c357233b9 100644
--- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5823ae9a382421609200d569799505706180b94c6923f315caa81f8767ac58f8
-size 131340
+oid sha256:95c5746de02c584536b1178614d15460bbdda633ddf44485261007618bd99f50
+size 132268
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png
index 96215547bb..bae0e74d1e 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d1622f9765fda65985a369d6958f7a5993696dd629c0c1013c0a1647cfb7b562
-size 50109
+oid sha256:25fdfa4b65263c02e10c6ddea16a3210a84722a742a66cd2d91b8b3f61968466
+size 49924
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png
index 70d41498a8..389ae383c8 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a94de003f65d2fb30ab42e4ac60802707abf1358b0defed7598a3aad69992f40
-size 48136
+oid sha256:dc99c7505aa48b7ccdbae1de6daec75c12b9293172c804ef3cf027f6b46c45ea
+size 47883
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png
index c17cbfdb66..45f5ce2bde 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fdab4bea33337547250355c29991f45c118e8c2c05abdf69b1824475feced5d6
-size 51241
+oid sha256:c446e3b322a1eb8634f2f643819672cfb626ca9e76c7e1fb5a10fc03a1ae7824
+size 51031
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png
index ef9c49d6c5..ff238fd979 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0f92c594febc463911c8418deeff3b94e5a8ce4171461d812a0f4f527ab0cf8e
-size 49245
+oid sha256:86260ffd841034c31327df736e0e127271f875a6cf383c688b7ea8c3a9d1dbd4
+size 48943
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png
index 9ba3d99527..cebea0c88b 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:81d8acdb1158c3e5c19b5a602e3b9afe7643f1bb77d43f51d3f5c4327478a76a
-size 50282
+oid sha256:9669ba8ab19e7f7f1322e973e6f39855113b45ab5f6b26e7b5230a2fe9ec1d68
+size 50067
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png
index 969e5b04bf..50cd50736a 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c225ad12c5a32e2392606f9afacc6dbc37478a141d5485e4942012c6b22ad052
-size 48046
+oid sha256:f1f66076e3b06df1471f367bed57f81d15272e3600eebc51cd131794e29d5dfb
+size 47767
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png
index a0192f0890..c4b21b86af 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3cfb1d8b7828692d5c318206770f9484822a1749b3e80a618b502811cf512d76
-size 60489
+oid sha256:ffe11074abe52ccd1c34f687f780c5185be370b2b92867ee0b686bad85f28890
+size 60210
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png
index c745fa256a..839c4a88ba 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ca67409e4af247b43d8594d6351419cd12894485357596aff7b26745e2df009a
-size 57687
+oid sha256:e9914f3ffd70dd83013ff6059539bda348ae64fc39a2419d621f8d198f6471cc
+size 57380
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png
index 635e14be9e..f47016a59f 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8b3a91d104a5949dde83ac38b3127afa342816c631a1e29d78e2ada1bcc906bb
-size 50150
+oid sha256:895ba0487ed933f4c8eddc99fe008032fe01454771ccf2987905aec357f3d9c0
+size 49972
diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png
index 573b6123ed..adfcb6a616 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:69c281829fbca15261f196d2134f933b789ff444cd7bebc88d05cdbb7553357f
-size 48012
+oid sha256:b5a435c5aa78ffda52d96d611ab869d006e6c95e8aaf4cd005d99128b4f60b1c
+size 47736
diff --git a/tools/adb/force_stop.sh b/tools/adb/force_stop.sh
new file mode 100755
index 0000000000..aaf9274010
--- /dev/null
+++ b/tools/adb/force_stop.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# Copyright (c) 2026 Element Creations Ltd.
+#
+# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+# Please see LICENSE files in the repository root for full details.
+
+package_name="io.element.android.x.debug"
+
+echo " => Force stop ${package_name}"
+
+adb shell am force-stop "${package_name}"
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index 8a7790be1e..8bd6f14c12 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -173,6 +173,7 @@
"includeRegex" : [
"screen_onboarding_.*",
"screen\\.onboarding\\..*",
+ "screen\\.change_server\\..*",
"screen\\.missing_key_backup\\..*",
"screen_login_.*",
"screen_server_confirmation_.*",
@@ -271,6 +272,7 @@
"screen_room_retry.*",
"screen_room_timeline.*",
"screen\\.room\\.timeline.*",
+ "error\\.sending_failure\\..*",
"screen\\.room_timeline.*",
"screen_room_typing.*",
"screen\\.image_edition\\..*",
diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt
index fcb3669a59..7eae1a5a78 100644
--- a/tools/manifest/gplay/release/aaptDump.txt
+++ b/tools/manifest/gplay/release/aaptDump.txt
@@ -1,4 +1,4 @@
-package: name='io.element.android.x' versionCode='202608010' versionName='26.08.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
+package: name='io.element.android.x' versionCode='202608020' versionName='26.08.2' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
sdkVersion:'24'
targetSdkVersion:'37'
uses-permission: name='android.permission.POST_NOTIFICATIONS'