mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge
This commit is contained in:
@@ -7,7 +7,6 @@
|
||||
"PR-Dependencies",
|
||||
],
|
||||
"ignoreDeps": [
|
||||
"string:app_name",
|
||||
"gradle",
|
||||
],
|
||||
"minimumReleaseAge": "7 days",
|
||||
|
||||
@@ -31,9 +31,6 @@ __pycache__/
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="© &#36;today.year Element Creations Ltd. Element Creations Ltd, Element Software SARL, Element Software Inc., and Element Software GmbH (the "Element Group") only make this file available under a proprietary license model. Without a proprietary license with us, you cannot use this file. The terms of the proprietary license agreement between you and any member of the Element Group shall always apply to your use of this file. Unauthorised use, copying, distribution, or modification of this file, via any medium, is strictly prohibited. For details about the licensing terms, you must either visit our website or contact a member of our sales team." />
|
||||
<option name="notice" value="© &#36;today.year Element Creations Ltd. SPDX-License-Identifier: LicenseRef-Element-Commercial SPDX-License-Identifier: LicenseRef-Element-Proprietary" />
|
||||
<option name="myName" value="Element Enterprise" />
|
||||
</copyright>
|
||||
</component>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Change account provider"
|
||||
visible: "Enter your account provider"
|
||||
timeout: 10000
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+83
@@ -1,3 +1,86 @@
|
||||
Changes in Element X v26.08.2
|
||||
=============================
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at 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
|
||||
=============================
|
||||
|
||||
|
||||
+28
-18
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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(
|
||||
+12
-5
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Unit>> { 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+19
@@ -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()
|
||||
}
|
||||
+4
-4
@@ -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(),
|
||||
+1
-1
@@ -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"))
|
||||
|
||||
+1
-1
Submodule enterprise updated: 3bf0df1f25...ae0001a17d
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: bug fixes and improvements.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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()
|
||||
|
||||
+7
-1
@@ -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 = {
|
||||
|
||||
+13
-3
@@ -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)
|
||||
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
interface PipController {
|
||||
fun setupPipCallbacks()
|
||||
suspend fun canEnterPip(): Boolean
|
||||
fun enterPip()
|
||||
fun exitPip()
|
||||
|
||||
+1
-1
@@ -250,7 +250,7 @@ class WebViewAudioManager(
|
||||
|
||||
hasRegisteredCallbacks = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Timber.d("Setting androidNativeBridge javascript interface in webview")
|
||||
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
|
||||
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
+4
-13
@@ -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 = "*"
|
||||
}
|
||||
|
||||
+2
-2
@@ -32,13 +32,13 @@ sealed interface RemoteEnterpriseConfigResult<out T> {
|
||||
is Success<T> -> data
|
||||
is Outdated<T> -> data
|
||||
is Error -> null
|
||||
NotFound -> null
|
||||
is NotFound -> null
|
||||
}
|
||||
|
||||
fun upToDateDataOrNull(): T? = when (this) {
|
||||
is Success<T> -> data
|
||||
is Outdated<T> -> null
|
||||
is Error -> null
|
||||
NotFound -> null
|
||||
is NotFound -> null
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -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<String> = 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
|
||||
}
|
||||
|
||||
-29
@@ -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<String?> = Result.success(null)
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class Factory : ContentScannerUrlProvider.Factory {
|
||||
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
|
||||
return DefaultContentScannerUrlProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-11
@@ -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<String> {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+10
-3
@@ -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 -> {
|
||||
|
||||
+41
-16
@@ -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 = {},
|
||||
|
||||
+5
@@ -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<SpaceFiltersState>
|
||||
|
||||
@Binds
|
||||
fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter<GlobalSearchState>
|
||||
}
|
||||
|
||||
+4
@@ -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<SpaceFiltersState>,
|
||||
private val globalSearchPresenter: Presenter<GlobalSearchState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<RoomListState> {
|
||||
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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+8
-1
@@ -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<RoomListState> {
|
||||
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,
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
+301
@@ -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<GlobalSearchState> {
|
||||
@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<GlobalSearchResults> 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+92
@@ -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<GlobalSearchResults>,
|
||||
val eventSink: (GlobalSearchEvent) -> Unit
|
||||
)
|
||||
|
||||
@Immutable
|
||||
enum class GlobalSearchTarget {
|
||||
ROOMS,
|
||||
MESSAGES,
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface GlobalSearchResults {
|
||||
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
|
||||
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : 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?,
|
||||
)
|
||||
+249
@@ -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<GlobalSearchState> {
|
||||
override val values: Sequence<GlobalSearchState>
|
||||
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<RoomAlias> = 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<UserId> = emptyList(),
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
pinnedEventIds: List<EventId> = emptyList(),
|
||||
roomCreators: List<UserId> = 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<GlobalSearchResults> = AsyncData.Uninitialized,
|
||||
eventSink: (GlobalSearchEvent) -> Unit = {},
|
||||
) = GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
isSearchActive = isSearchActive,
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = results,
|
||||
eventSink = eventSink
|
||||
)
|
||||
+564
@@ -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<RoomListRoomSummary>,
|
||||
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<MessageSearchResultItem>,
|
||||
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 = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -46,6 +46,8 @@ class RoomListSearchDataSource(
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
|
||||
val loadingState = roomList.loadingState
|
||||
|
||||
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.summaries
|
||||
.map { roomSummaries ->
|
||||
roomSummaries
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="start_searching_title">Start searching…</string>
|
||||
<string name="start_searching_subtitle">Search for chats and messages</string>
|
||||
<string name="search_section_chats">Chats</string>
|
||||
<string name="search_from_your_messages_header">From your messages</string>
|
||||
<string name="search_section_messages">Messages</string>
|
||||
<string name="search_no_results_title">No results</string>
|
||||
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
|
||||
</resources>
|
||||
+4
-2
@@ -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()
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
-1
@@ -277,7 +277,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
|
||||
setSafeContent {
|
||||
HomeView(
|
||||
homeState = aHomeState(roomListState = state),
|
||||
onRoomClick = onRoomClick,
|
||||
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
|
||||
onSettingsClick = onSettingsClick,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
|
||||
+305
@@ -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,
|
||||
)
|
||||
}
|
||||
+7
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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<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
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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<DeclineAndBlockNode>(buildContext, plugins = listOf(inputs))
|
||||
return parentNode.createNode<DeclineAndBlockNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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<DeclineAndBlockEvents>(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<DeclineAndBlockEvents>()
|
||||
@@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() {
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setDeclineAndBlockView(
|
||||
state: DeclineAndBlockState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onDeclineSuccess: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
DeclineAndBlockView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onDeclineSuccess = onDeclineSuccess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
inviteData: InviteData,
|
||||
callback: DeclineInviteAndBlockEntryPoint.Callback,
|
||||
): Node {
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
-43
@@ -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<ConfirmAccountProviderNode>(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<ChangeAccountProviderNode>(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<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
is NavTarget.LoginPassword -> {
|
||||
val inputs = LoginPasswordNode.Inputs(
|
||||
initialLogin = navTarget.initialLogin,
|
||||
|
||||
+42
-4
@@ -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<AccountProvider> = MutableStateFlow(defaultAccountProvider)
|
||||
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider)
|
||||
|
||||
val flow: StateFlow<AccountProvider> = 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)
|
||||
}
|
||||
|
||||
|
||||
-77
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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<ChangeServerState> {
|
||||
@Composable
|
||||
override fun present(): ChangeServerState {
|
||||
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
|
||||
val changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>> = remember {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ class ChangeServerPresenter(
|
||||
|
||||
private suspend fun changeServer(
|
||||
data: AccountProvider,
|
||||
changeServerAction: MutableState<AsyncData<Unit>>,
|
||||
changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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<Unit>,
|
||||
// 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<MatrixHomeServerDetails>,
|
||||
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
|
||||
val eventSink: (ChangeServerEvents) -> Unit
|
||||
)
|
||||
|
||||
+2
-1
@@ -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<ChangeServerState> {
|
||||
@@ -45,7 +46,7 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
|
||||
}
|
||||
|
||||
fun aChangeServerState(
|
||||
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
changeServerAction: AsyncData<MatrixHomeServerDetails> = AsyncData.Uninitialized,
|
||||
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
||||
) = ChangeServerState(
|
||||
changeServerAction = changeServerAction,
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+23
-18
@@ -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<LoginModeState> {
|
||||
@Composable
|
||||
override fun present(): LoginModeState {
|
||||
@@ -73,26 +75,28 @@ class LoginModePresenter(
|
||||
|
||||
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState<AsyncData<LoginMode>>) {
|
||||
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()
|
||||
|
||||
-14
@@ -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,
|
||||
)
|
||||
-89
@@ -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<List<HomeserverData>> = 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<HomeserverData>())
|
||||
// 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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-51
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-57
@@ -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<ChangeServerState>,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) : Presenter<ChangeAccountProviderState> {
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-19
@@ -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<AccountProvider>,
|
||||
val canSearchForAccountProviders: Boolean,
|
||||
val changeServerState: ChangeServerState,
|
||||
)
|
||||
-37
@@ -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<ChangeAccountProviderState> {
|
||||
override val values: Sequence<ChangeAccountProviderState>
|
||||
get() = sequenceOf(
|
||||
aChangeAccountProviderState(),
|
||||
aChangeAccountProviderState(canSearchForAccountProviders = false),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
fun aChangeAccountProviderState(
|
||||
accountProviders: List<AccountProvider> = listOf(
|
||||
anAccountProvider()
|
||||
),
|
||||
canSearchForAccountProviders: Boolean = true,
|
||||
changeServerState: ChangeServerState = aChangeServerState(),
|
||||
) = ChangeAccountProviderState(
|
||||
accountProviders = accountProviders.toImmutableList(),
|
||||
canSearchForAccountProviders = canSearchForAccountProviders,
|
||||
changeServerState = changeServerState,
|
||||
)
|
||||
-129
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
-2
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
+108
-9
@@ -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<LoginModeState>,
|
||||
private val changeServerPresenter: Presenter<ChangeServerState>,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) : Presenter<ConfirmAccountProviderState> {
|
||||
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<String?>(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<String?>(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<String?>(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("://")
|
||||
|
||||
+16
-4
@@ -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
|
||||
}
|
||||
|
||||
+12
-4
@@ -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<Confir
|
||||
override val values: Sequence<ConfirmAccountProviderState>
|
||||
get() = sequenceOf(
|
||||
aConfirmAccountProviderState(),
|
||||
aConfirmAccountProviderState(
|
||||
accountProviderInput = "element",
|
||||
accountProviderSuggestion = "element.io",
|
||||
),
|
||||
aConfirmAccountProviderState(
|
||||
isAccountCreation = true,
|
||||
),
|
||||
@@ -31,13 +35,17 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
|
||||
}
|
||||
|
||||
private fun aConfirmAccountProviderState(
|
||||
accountProvider: AccountProvider = anAccountProvider(),
|
||||
accountProviderInput: String = "matrix.org",
|
||||
accountProviderSuggestion: String? = null,
|
||||
isAccountCreation: Boolean = false,
|
||||
loginModeState: LoginModeState = aLoginModeState(),
|
||||
changeServerState: ChangeServerState = aChangeServerState(),
|
||||
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
|
||||
) = ConfirmAccountProviderState(
|
||||
accountProvider = accountProvider,
|
||||
accountProviderInput = accountProviderInput,
|
||||
accountProviderSuggestion = accountProviderSuggestion,
|
||||
isAccountCreation = isAccountCreation,
|
||||
loginModeState = loginModeState,
|
||||
changeServerState = changeServerState,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+180
-39
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -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<LoginPasswordState> {
|
||||
@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 ->
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
-17
@@ -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
|
||||
}
|
||||
-49
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-77
@@ -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<ChangeServerState>,
|
||||
) : Presenter<SearchAccountProviderState> {
|
||||
@Composable
|
||||
override fun present(): SearchAccountProviderState {
|
||||
var userInput by rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val changeServerState = changeServerPresenter.present()
|
||||
|
||||
val data: MutableState<AsyncData<List<HomeserverData>>> = 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<AsyncData<List<HomeserverData>>>) = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user