diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c16310cf43..86ab2f1b1e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -7,7 +7,6 @@ "PR-Dependencies", ], "ignoreDeps": [ - "string:app_name", "gradle", ], "minimumReleaseAge": "7 days", diff --git a/.gitignore b/.gitignore index 77acaadb20..a50ae12c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ __pycache__/ # Local configuration file (sdk path, etc) local.properties -# Proguard folder generated by Eclipse -proguard/ - # Log Files *.log diff --git a/.idea/copyright/Element_Enterprise.xml b/.idea/copyright/Element_Enterprise.xml index b556049368..f7637c76a5 100644 --- a/.idea/copyright/Element_Enterprise.xml +++ b/.idea/copyright/Element_Enterprise.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml deleted file mode 100644 index b07fa5cc09..0000000000 --- a/.maestro/tests/account/changeServer.yaml +++ /dev/null @@ -1,21 +0,0 @@ -appId: ${MAESTRO_APP_ID} ---- -- tapOn: - id: "login-change_server" -- takeScreenshot: build/maestro/200-ChangeServer -- tapOn: "matrix.org" -- tapOn: - id: "login-change_server" -- tapOn: "Other" -- tapOn: - id: "change_server-server" -- inputText: "element" -- hideKeyboard -- extendedWaitUntil: - visible: "element.io" - timeout: 10000 -- tapOn: "element.io" -# Revert to matrix.org -- tapOn: - id: "login-change_server" -- tapOn: "matrix.org" diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 873843b22d..81922c7f38 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID} - tapOn: "Sign in manually" - runFlow: ../assertions/assertLoginDisplayed.yaml - takeScreenshot: build/maestro/100-SignIn -- runFlow: changeServer.yaml -- runFlow: ../assertions/assertLoginDisplayed.yaml +# The account provider field defaults to matrix.org, so continue straight to the MAS login. - tapOn: id: "login-continue" ## MAS page diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml index 6d8558c38e..c32f23c323 100644 --- a/.maestro/tests/assertions/assertLoginDisplayed.yaml +++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml @@ -1,5 +1,5 @@ appId: ${MAESTRO_APP_ID} --- - extendedWaitUntil: - visible: "Change account provider" + visible: "Enter your account provider" timeout: 10000 diff --git a/AGENTS.md b/AGENTS.md index 924d8bb76f..917285ab30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ PRs must meet these rules. - Use `@PreviewsDayNight` for consistency. - Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. - Wrap previews in `ElementPreview { ... }`. +- When writing tests, never try to record the screenshots, the CI will do it. --- diff --git a/CHANGES.md b/CHANGES.md index 2955fc8ad7..db18e59251 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,86 @@ +Changes in Element X v26.08.2 +============================= + + + +**Note: this release in an internal release, it will not be pushed to the stores.** + +## What's Changed +### ✨ Features +* Remove the legacy web-based account creation for matrix.org by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7412 +### 🙌 Improvements +* Move the custom tile server URL config to the Element wellknown content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7416 +* Explain a rate limiting push gateway in the notification troubleshooter by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7448 +* Move the `:libraries:wellknown` logic into `enterprise` modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7429 +* Refresh the room members when a mention is started by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7438 +* Close the invite screen once the invite has been declined by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7440 +* Use Profiles sliding sync extension check for own profile subscription by @ganfra in https://github.com/element-hq/element-x-android/pull/7403 +* Simplify choosing the account provider during sign in, with autocomplete of previously used ones by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7394 +### 🐛 Bugfixes +* Fix wrong TextComposer placeholder displayed when in a thread. by @bmarty in https://github.com/element-hq/element-x-android/pull/7413 +* Fix bug reports failing to send after the app has crashed by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7395 +* Fix outgoing session verification stuck with incomplete recovery setup by @hardcore-sushi in https://github.com/element-hq/element-x-android/pull/7433 +* Keep transparency when an image is shared into the app with an imprecise mime type by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7452 +* Don't restart the sync service if the client is shutting down by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7472 +* Normalize the applicationName used in user agent by @bmarty in https://github.com/element-hq/element-x-android/pull/7477 +* Keep playing a voice message when another app plays a short sound by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7454 +* Keep the send button when the composer is emptied while editing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7456 +* Do not open the room again when the app is resumed from the recents list by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7436 +* Stop waiting for a room alias that the remote server never resolves by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7446 +### 🧱 Build +* build-rust-sdk: print the command we run by @richvdh in https://github.com/element-hq/element-x-android/pull/7407 +* Remove license check on Pro modules. by @bmarty in https://github.com/element-hq/element-x-android/pull/7418 +* Improve build time config and update the Firebase configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/7473 +* Make the PUSHER_APP_ID configurable at build time. by @bmarty in https://github.com/element-hq/element-x-android/pull/7478 +### 📄 Documentation +* Add KDoc to all the interfaces of the api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/7426 +### 🚧 In development 🚧 +* [LinkNewDevice] Ensure the notice screen is also protected by the timeout. by @bmarty in https://github.com/element-hq/element-x-android/pull/7428 +### Dependency upgrades +* Update kotlin to v2.3.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7396 +* Update plugin sonarqube to v7.4.0.8496 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7415 +* Update metro to v1.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7421 +* Update roborazzi to v1.71.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7423 +* Update dependency io.sentry:sentry-android to v8.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7414 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7420 +* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7431 +* Update dependency org.maplibre.compose:maplibre-compose to v0.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7432 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7460 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7479 +* Update media3 to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7427 +* Update dependency androidx.compose:compose-bom to v2026.08.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7483 +### 💼 Element Pro +* Add hooks to allow providing additional feature flags in EX pro by @richvdh in https://github.com/element-hq/element-x-android/pull/7419 +* Use the right base URL for the ESS config/well known endpoints by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7469 +### Others +* Do not display pinned events that the client cannot render by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7379 +* Hide the forward action on live location shares by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7343 +* Do not fail the screenshot recording job when there is nothing to commit by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7355 +* Show the account a UnifiedPush registration belongs to in the distributor by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7345 +* Stop repeating messages that are already shown in a room notification by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7370 +* Turn a matrix: URI in a message into a pill by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7354 +* Send a message with Enter on a physical keyboard, Shift+Enter for a new line by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7353 +* Do not group timeline messages sent more than five minutes apart by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7349 +* Linkify URLs whose host is a bracketed IPv6 address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7347 +* Add missing tests for `RustTemporaryMatrixClient` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7417 +* Revert "Keep manually revealed media visible when re-entering a room" by @bmarty in https://github.com/element-hq/element-x-android/pull/7424 +* Fix outdated documentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/7430 +* Sync strings - New language supported: Arabic by @bmarty in https://github.com/element-hq/element-x-android/pull/7461 +* Keep the focused login field visible above the keyboard by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7455 +* Relax image compression so photos and screenshots keep more detail by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7451 +* Open the user profile of a user id that contains a slash by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7445 +* Remove the F-Droid dex patching step from the release script by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7442 +* Convert markdown to HTML when a caption is edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7441 +* Exclude carriage returns from the login and password fields by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7439 +* Show an error when the room to leave cannot be found by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7437 +* Give the room name in the timeline top bar a 48dp tap target by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7450 +* Model the fake voice recorder on a cancellable recording job by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7466 + +## New Contributors +* @hardcore-sushi made their first contribution in https://github.com/element-hq/element-x-android/pull/7433 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.1...v26.08.2 + Changes in Element X v26.08.1 ============================= diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ecd5ed8e7b..f85d8dc33c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,24 +127,11 @@ android { optimization { enable = true keepRules { - files.add(File(projectDir, "common-proguard-rules.pro")) - files.add(getDefaultProguardFile("proguard-android-optimize.txt")) - - // Depending on whether the app flavor is enterprise or not we want to use different proguard rules. - val flavorProguardFile = if (isEnterpriseBuild) { - // Custom rules for enterprise builds - File(projectDir, "enterprise-proguard-rules.pro") - } else { - // These default rules prevent the OSS app from being obfuscated - File(projectDir, "default-proguard-rules.pro") - } - - if (flavorProguardFile.exists()) { - files.add(flavorProguardFile) - } else { - logger.warn("Proguard file ${flavorProguardFile.absolutePath} does not exist") - } + // Equivalent of adding `getDefaultProguardFile("proguard-android-optimize.txt")` (this is the default value). + includeDefault = true } + // Our custom keep rules are registered as `keepRules` source folders in the `androidComponents` block below, + // as the former `keepRules.files` DSL is deprecated since AGP 9. } } @@ -229,6 +216,30 @@ androidComponents { ) onVariants { variant -> + // Register the R8 keep rules source folders for optimized build types (release, nightly). + // Replaces the deprecated `optimization.keepRules.files` DSL (AGP 9+). + if (variant.buildType != "debug") { + variant.sources.keepRules?.let { keepRules -> + // Common rules, always applied. + keepRules.addStaticSourceDirectory("proguard/common") + + // Depending on whether the app flavor is enterprise or not we want to use different proguard rules. + val flavorProguardDir = if (isEnterpriseBuild) { + // Custom rules for enterprise builds + "../enterprise/proguard" + } else { + // Custom fules for FOSS builds + "proguard/foss" + } + + if (File(projectDir, flavorProguardDir).exists()) { + keepRules.addStaticSourceDirectory(flavorProguardDir) + } else { + logger.warn("Proguard folder ${File(projectDir, flavorProguardDir).absolutePath} does not exist") + } + } + } + // Assigns a different version code for each output APK // other than the universal APK. variant.outputs.forEach { output -> diff --git a/app/common-proguard-rules.pro b/app/proguard/common/common-proguard-rules.keep similarity index 100% rename from app/common-proguard-rules.pro rename to app/proguard/common/common-proguard-rules.keep diff --git a/app/default-proguard-rules.pro b/app/proguard/foss/default-proguard-rules.keep similarity index 100% rename from app/default-proguard-rules.pro rename to app/proguard/foss/default-proguard-rules.keep diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index 7564db5d95..39c70e35d8 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -18,7 +18,7 @@ import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import io.element.android.appconfig.ApplicationConfig -import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta @@ -74,17 +74,18 @@ object AppModule { fun providesBuildMeta( @ApplicationContext context: Context, buildType: BuildType, - enterpriseService: EnterpriseService, + isEnterpriseBuild: IsEnterpriseBuild, ): BuildMeta { val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name) + val isEnterprise = isEnterpriseBuild() return BuildMeta( isDebuggable = BuildConfig.DEBUG, buildType = buildType, applicationName = applicationName, - productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME, - desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME, + productionApplicationName = if (isEnterprise) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME, + desktopApplicationName = if (isEnterprise) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME, applicationId = BuildConfig.APPLICATION_ID, - isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + isEnterpriseBuild = isEnterprise, // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, lowPrivacyLoggingEnabled = false, versionName = BuildConfig.VERSION_NAME, diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt deleted file mode 100644 index d36fb742b0..0000000000 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.appconfig - -object PushConfig { - /** - * Note: pusher_app_id cannot exceed 64 chars. - */ - const val PUSHER_APP_ID: String = "im.vector.app.android" -} diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 054e9525c7..12b95d9a09 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -61,17 +61,23 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.features.login.test) testImplementation(projects.features.share.test) + testImplementation(projects.libraries.accountselect.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixmedia.test) testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.features.announcement.test) testImplementation(projects.features.forward.test) testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.features.preferences.test) testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.signedout.test) testImplementation(projects.services.apperror.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.analytics.test) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index d7b768132b..dab9b7828c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -51,7 +51,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState @@ -157,7 +157,7 @@ class LoggedInFlowNode( private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, + private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -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, ) } @@ -685,7 +685,7 @@ class LoggedInFlowNode( val currentMapTilerConfig = LocalMapTilerConfig.current val updatedMapTilerConfig by produceState(currentMapTilerConfig) { - value = remoteEnterpriseConfigProvider.get(matrixClient.sessionId).dataOrNull()?.mapTilerConfig ?: currentMapTilerConfig + value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig } ElementThemeApp( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index acf7b66db9..c9deaeb3b3 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -32,7 +32,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.annotations.ContributesNode -import io.element.android.appnav.di.MatrixSessionCache import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.room.RoomFlowNode @@ -40,6 +39,7 @@ import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl @@ -109,6 +109,13 @@ class RootFlowNode( buildContext = buildContext, plugins = plugins ) { + /** + * Login params coming from a launch or new [Intent], waiting to be consumed by the not logged in flow. + * Kept here so that the root nav target can be computed from both the logged in state and the pending + * login params, whatever the order in which the intent and the first nav state emission are processed. + */ + private var pendingLoginParams: LoginParams? = null + override fun onBuilt() { analyticsColdStartWatcher.start() appCoroutineScope.launch { @@ -152,7 +159,7 @@ class RootFlowNode( } } LoggedInState.NotLoggedIn -> { - switchToNotLoggedInFlow(null) + switchToNotLoggedInFlow(pendingLoginParams) } } } @@ -194,15 +201,18 @@ class RootFlowNode( } private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + pendingLoginParams = null backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) } private fun switchToNotLoggedInFlow(params: LoginParams?) { + Timber.d("switchToNotLoggedInFlow, hasLoginParams=${params != null}") matrixSessionCache.removeAll() backstack.safeRoot(NavTarget.NotLoggedInFlow(params)) } private fun switchToSignedOutFlow(sessionId: SessionId) { + pendingLoginParams = null backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) } @@ -319,6 +329,7 @@ class RootFlowNode( } override fun onDone() { + pendingLoginParams = null backstack.pop() } } @@ -418,6 +429,7 @@ class RootFlowNode( Timber.w("Login link ignored, multi account is disabled") } } else { + pendingLoginParams = params switchToNotLoggedInFlow(params) } } else { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index ee316f00aa..5d3a85fa9a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -76,6 +76,7 @@ class IntentResolver( } private fun Intent.canBeIgnored(): Boolean { + if (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return true return action == Intent.ACTION_MAIN && categories?.contains(Intent.CATEGORY_LAUNCHER) == true } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 9a91e9765b..8b02752a64 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -11,7 +11,7 @@ package io.element.android.appnav.root import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject -import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.preferences.api.CacheService import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt similarity index 98% rename from appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt rename to appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt index 11337f2e0a..ffae5f2b17 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import androidx.annotation.VisibleForTesting import com.bumble.appyx.core.state.MutableSavedStateMap @@ -115,7 +115,7 @@ class MatrixSessionCache( private fun onNewMatrixClient(matrixClient: MatrixClient) { val syncOrchestrator = syncOrchestratorFactory.create( - syncService = matrixClient.syncService, + matrixClient = matrixClient, sessionCoroutineScope = matrixClient.sessionCoroutineScope, ) sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt similarity index 91% rename from appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt rename to appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt index 1ce423a569..3a72c10ab7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import androidx.annotation.VisibleForTesting import dev.zacsweers.metro.Assisted @@ -16,7 +16,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope -import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.recordTransaction @@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds @AssistedInject class SyncOrchestrator( - @Assisted private val syncService: SyncService, + @Assisted private val matrixClient: MatrixClient, @Assisted sessionCoroutineScope: CoroutineScope, private val appForegroundStateService: AppForegroundStateService, private val networkMonitor: NetworkMonitor, @@ -47,13 +47,15 @@ class SyncOrchestrator( @AssistedFactory interface Factory { fun create( - syncService: SyncService, + matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope, ): SyncOrchestrator } private val tag = "SyncOrchestrator" + private val syncService = matrixClient.syncService + private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag) private val started = AtomicBoolean(false) @@ -94,7 +96,7 @@ class SyncOrchestrator( appForegroundStateService.isInCall, appForegroundStateService.isSyncingNotificationEvent, appForegroundStateService.hasRingingCall, - appForegroundStateService.isSharingLiveLocation + appForegroundStateService.isSharingLiveLocation, ) val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } } combine( @@ -103,6 +105,11 @@ class SyncOrchestrator( networkMonitor.connectivity, isAppActiveFlow, ) { syncState, networkState, isAppActive -> + if (matrixClient.isShuttingDown) { + Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again") + return@combine SyncStateAction.NoOp + } + val isNetworkAvailable = networkState == NetworkStatus.Connected Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable") diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt new file mode 100644 index 0000000000..fea30c8019 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.appnav + +import android.content.Intent +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.activeElement +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper +import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.intent.IntentResolver +import io.element.android.appnav.root.RootNavStateFlowFactory +import io.element.android.appnav.root.RootPresenter +import io.element.android.appnav.session.FakeSyncOrchestratorFactory +import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.api.LoginParams +import io.element.android.features.login.test.FakeLoginEntryPoint +import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl +import io.element.android.features.preferences.test.FakeCacheService +import io.element.android.features.rageshake.test.FakeBugReportEntryPoint +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.share.test.FakeShareIntentHandler +import io.element.android.features.signedout.test.FakeSignedOutEntryPoint +import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.FakeSdkMetadata +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher +import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings +import io.element.android.tests.testutils.node.FakeParentNode +import io.element.android.tests.testutils.presenter.NotUsedPresenter +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" + +private val A_LOGIN_PARAMS = LoginParams( + accountProvider = "example.com", + loginHint = "mxid:@alice:example.com", +) + +private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( + accountProvider = A_LOGIN_PARAMS.accountProvider, + loginHint = A_LOGIN_PARAMS.loginHint, +) + +class RootFlowNodeTest : RobolectricTest() { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest { + var loginEntryPointParams: LoginEntryPoint.Params? = null + val rootFlowNode = createRootFlowNode( + loginEntryPoint = FakeLoginEntryPoint { buildContext, params -> + loginEntryPointParams = params + node(buildContext) {} + }, + ) + rootFlowNode.parentNodeTestHelper() + // The intent is handled first, this is what happens when the deep link cold starts the app. + rootFlowNode.handleIntent(aLoginIntent()) + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + // Then the first nav state emission lands, it must not override the login params. + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + } + + @Test + fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest { + var loginEntryPointParams: LoginEntryPoint.Params? = null + val rootFlowNode = createRootFlowNode( + loginEntryPoint = FakeLoginEntryPoint { buildContext, params -> + loginEntryPointParams = params + node(buildContext) {} + }, + ) + rootFlowNode.parentNodeTestHelper() + // The nav state is observed first, this is what happens when the app is already running. + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(null)) + rootFlowNode.handleIntent(aLoginIntent()) + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + } + + private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK)) + + private fun TestScope.createRootFlowNode( + loginEntryPoint: LoginEntryPoint, + sessionStore: SessionStore = InMemorySessionStore(), + ): RootFlowNode { + val matrixSessionCache = MatrixSessionCache( + authenticationService = FakeMatrixAuthenticationService(), + syncOrchestratorFactory = FakeSyncOrchestratorFactory(), + analyticsService = FakeAnalyticsService(), + ) + val parentNode = FakeParentNode( + graph = FakeNodeFactoriesBindings( + mapOf( + NotLoggedInFlowNode::class to AssistedNodeFactory { buildContext, plugins -> + NotLoggedInFlowNode( + buildContext = buildContext, + plugins = plugins, + loginEntryPoint = loginEntryPoint, + imageLoaderHolder = FakeImageLoaderHolder(), + analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + ) + } + ) + ) + ) + return RootFlowNode( + buildContext = BuildContext( + ancestryInfo = AncestryInfo.Child(anchor = parentNode), + savedStateMap = null, + customisations = NodeCustomisationDirectoryImpl(), + ), + plugins = emptyList(), + sessionStore = sessionStore, + accountProviderAccessControl = FakeAccountProviderAccessControl { true }, + navStateFlowFactory = RootNavStateFlowFactory( + sessionStore = sessionStore, + cacheService = FakeCacheService(), + matrixSessionCache = matrixSessionCache, + imageLoaderHolder = FakeImageLoaderHolder(), + sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(), + ), + matrixSessionCache = matrixSessionCache, + presenter = RootPresenter( + crashDetectionPresenter = NotUsedPresenter(), + rageshakeDetectionPresenter = NotUsedPresenter(), + appErrorStateService = FakeAppErrorStateService(), + analyticsService = FakeAnalyticsService(), + sdkMetadata = FakeSdkMetadata("sha"), + ), + bugReportEntryPoint = FakeBugReportEntryPoint(), + signedOutEntryPoint = FakeSignedOutEntryPoint(), + accountSelectEntryPoint = FakeAccountSelectEntryPoint(), + intentResolver = IntentResolver( + deeplinkParser = { null }, + loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS }, + oAuthIntentResolver = FakeOAuthIntentResolver { null }, + permalinkParser = FakePermalinkParser(), + shareIntentHandler = FakeShareIntentHandler(), + ), + oAuthActionFlow = FakeOAuthActionFlow(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), + analyticsService = FakeAnalyticsService(), + analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + appCoroutineScope = backgroundScope, + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 7309ca6ac2..32d0ec8f56 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -8,10 +8,11 @@ package io.element.android.appnav -import io.element.android.appnav.di.SyncOrchestrator +import io.element.android.appnav.session.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService @@ -40,7 +41,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -60,7 +61,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -85,7 +86,7 @@ class SyncOrchestratorTest { val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -117,7 +118,7 @@ class SyncOrchestratorTest { val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -165,7 +166,7 @@ class SyncOrchestratorTest { initialIsSyncingNotificationEventValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -208,7 +209,7 @@ class SyncOrchestratorTest { initialIsSyncingNotificationEventValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -252,7 +253,7 @@ class SyncOrchestratorTest { initialHasRingingCall = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -296,7 +297,7 @@ class SyncOrchestratorTest { initialIsInCallValue = true, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -339,7 +340,7 @@ class SyncOrchestratorTest { initialIsInCallValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -369,7 +370,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -381,12 +382,44 @@ class SyncOrchestratorTest { startSyncRecorder.assertions().isNeverCalled() } + @Test + fun `when the client is shutting down, the orchestrator does nothing`() = runTest { + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + initialIsSyncingNotificationEventValue = false, + initialIsInCallValue = false, + ) + + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }), + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // These should still not trigger a sync, sync the client is shutting down + networkMonitor.givenNetworkBlocked(false) + appForegroundStateService.updateIsInCallState(true) + appForegroundStateService.isInForeground.value = true + + advanceTimeBy(10.seconds) + + startSyncRecorder.assertions().isNeverCalled() + } + private fun TestScope.createSyncOrchestrator( - syncService: FakeSyncService = FakeSyncService(), + matrixClient: FakeMatrixClient = FakeMatrixClient(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), ) = SyncOrchestrator( - syncService = syncService, + matrixClient = matrixClient, sessionCoroutineScope = backgroundScope, networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 841f79678b..ddeae41a78 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -45,6 +45,24 @@ class IntentResolverTest : RobolectricTest() { assertThat(result).isNull() } + @Test + fun `resolve intent replayed from the history should return null`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = null, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + @Test fun `test resolve navigation intent root`() { val sut = createIntentResolver( diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt new file mode 100644 index 0000000000..586c8f0804 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.session + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.CoroutineScope + +class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { + override fun create( + matrixClient: MatrixClient, + sessionCoroutineScope: CoroutineScope, + ): SyncOrchestrator = lambdaError() +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt similarity index 97% rename from appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt index 36fa471dd0..bf315ddb01 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt @@ -6,12 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat import io.element.android.features.networkmonitor.test.FakeNetworkMonitor -import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService @@ -138,11 +138,11 @@ class MatrixSessionCacheTest { return object : SyncOrchestrator.Factory { override fun create( - syncService: SyncService, + matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope, ): SyncOrchestrator { return SyncOrchestrator( - syncService = syncService, + matrixClient = matrixClient, sessionCoroutineScope = sessionCoroutineScope, appForegroundStateService = FakeAppForegroundStateService(), networkMonitor = FakeNetworkMonitor(), diff --git a/build.gradle.kts b/build.gradle.kts index 0e0df9dbc6..6bbc568c66 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -98,7 +98,7 @@ allprojects { // This is disabled by default, but the CI will enforce this. // You can override by passing `-PallWarningsAsErrors=true` in the command line // Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file - allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true" + allWarningsAsErrors = findProperty("allWarningsAsErrors") == "true" // Uncomment to suppress Compose Kotlin compiler compatibility warning // freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) diff --git a/enterprise b/enterprise index f5f910856c..ae0001a17d 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit f5f910856cfd4f982d1dcce16a80f8fedfbcbd31 +Subproject commit ae0001a17d503c9051704eeb6d7ff06609f87242 diff --git a/fastlane/metadata/android/en-US/changelogs/202608020.txt b/fastlane/metadata/android/en-US/changelogs/202608020.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202608020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt index 1af13e148e..2053ff5102 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt @@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip import io.element.android.features.call.impl.utils.PipController sealed interface PictureInPictureEvent { + data object OnCallStarted : PictureInPictureEvent data class SetPipController(val pipController: PipController) : PictureInPictureEvent data object EnterPictureInPicture : PictureInPictureEvent data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent + data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt index 2dc16d7f26..aeb507f6ec 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt @@ -38,6 +38,9 @@ class PictureInPicturePresenter( fun handleEvent(event: PictureInPictureEvent) { when (event) { + is PictureInPictureEvent.OnCallStarted -> { + pipController?.setupPipCallbacks() + } is PictureInPictureEvent.SetPipController -> { pipController = event.pipController } @@ -55,6 +58,10 @@ class PictureInPicturePresenter( pipController?.exitPip() } } + is PictureInPictureEvent.SetPipOrientation -> { + Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}") + pipView?.setPipOrientation(event.orientation) + } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt index 90f74a37fd..aa1ab79dd0 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt @@ -9,6 +9,7 @@ package io.element.android.features.call.impl.pip interface PipView { + fun setPipOrientation(orientation: Int?) fun setPipParams() fun enterPipMode(): Boolean fun hangUp() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt index e96033806b..33c3024f83 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -118,6 +118,7 @@ internal fun CallScreenView( if (webViewAudioManager?.isInCallMode?.get() == false) { Timber.d("URL $url is loaded, starting in-call audio mode") webViewAudioManager?.onCallStarted() + pipState.eventSink(PictureInPictureEvent.OnCallStarted) } else { Timber.d("Can't start in-call audio mode since the app is already in it.") } @@ -130,7 +131,12 @@ internal fun CallScreenView( onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, ) state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor)) - val pipController = WebViewPipController(webView) + val pipController = WebViewPipController( + webView = webView, + updatePipOrientation = { orientation -> + pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation)) + } + ) pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) }, onDestroyWebView = { diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index 9a093ff5a6..5ed9e29fe2 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui import android.Manifest import android.app.PictureInPictureParams import android.content.Intent +import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.util.Rational import android.view.WindowManager import android.webkit.PermissionRequest import androidx.activity.compose.setContent @@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger +import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.bindings import io.element.android.libraries.audio.api.AudioFocus @@ -86,6 +87,8 @@ class ElementCallActivity : private var eventSink: ((CallScreenEvent) -> Unit)? = null + private var currentPipOrientation: Int? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -199,6 +202,7 @@ class ElementCallActivity : } DisposableEffect(Unit) { val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> + setPipParams() pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode)) if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call") @@ -296,11 +300,17 @@ class ElementCallActivity : } } + override fun setPipOrientation(orientation: Int?) { + currentPipOrientation = orientation + setPictureInPictureParams(getPictureInPictureParams()) + } + @RequiresApi(Build.VERSION_CODES.O) private fun getPictureInPictureParams(): PictureInPictureParams { + // Portrait for calls seems more appropriate as a fallback value + val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT return PictureInPictureParams.Builder() - // Portrait for calls seems more appropriate - .setAspectRatio(Rational(3, 5)) + .setAspectRatioFromOrientation(orientation) .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setAutoEnterEnabled(true) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt index b259816f9d..f7788ad520 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt @@ -9,6 +9,7 @@ package io.element.android.features.call.impl.utils interface PipController { + fun setupPipCallbacks() suspend fun canEnterPip(): Boolean fun enterPip() fun exitPip() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt index e68e57118b..3290269971 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -250,7 +250,7 @@ class WebViewAudioManager( hasRegisteredCallbacks = true } - } + }, ) Timber.d("Setting androidNativeBridge javascript interface in webview") webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge") diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt index 12f8dfd00f..88648e84fa 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt @@ -8,7 +8,10 @@ package io.element.android.features.call.impl.utils +import android.content.res.Configuration +import android.webkit.JavascriptInterface import android.webkit.WebView +import timber.log.Timber import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine */ class WebViewPipController( private val webView: WebView, + updatePipOrientation: (orientation: Int?) -> Unit, ) : PipController { + init { + // Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it + webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge") + } + + override fun setupPipCallbacks() { + Timber.d("Adding callback in controls.onPipMediaOrientationUpdate") + webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null) + } + override suspend fun canEnterPip(): Boolean { return suspendCoroutine { continuation -> webView.evaluateJavascript("controls.canEnterPip()") { result -> @@ -36,3 +50,19 @@ class WebViewPipController( webView.evaluateJavascript("controls.disablePip()", null) } } + +private class PipBridge( + private val onOrientationChange: (orientation: Int?) -> Unit, +) { + @JavascriptInterface + fun setPipOrientation(orientation: String?) { + Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation") + // This callback is used to update the picture-in-picture orientation in the WebView when it changes + val orientation = when (orientation) { + "portrait" -> Configuration.ORIENTATION_PORTRAIT + "landscape" -> Configuration.ORIENTATION_LANDSCAPE + else -> return + } + onOrientationChange(orientation) + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt index 5153f79c31..f2eca681e9 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt @@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController import io.element.android.tests.testutils.lambda.lambdaError class FakePipController( + private val setupPipCallbacksResult: () -> Unit = { lambdaError() }, private val canEnterPipResult: () -> Boolean = { lambdaError() }, private val enterPipResult: () -> Unit = { lambdaError() }, private val exitPipResult: () -> Unit = { lambdaError() }, ) : PipController { + override fun setupPipCallbacks() = setupPipCallbacksResult() + override suspend fun canEnterPip(): Boolean = canEnterPipResult() override fun enterPip() = enterPipResult() diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt index 55e94312bf..adfcde2c38 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt @@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError class FakePipView( private val setPipParamsResult: () -> Unit = { lambdaError() }, private val enterPipModeResult: () -> Boolean = { lambdaError() }, - private val handUpResult: () -> Unit = { lambdaError() } + private val handUpResult: () -> Unit = { lambdaError() }, + private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() }, ) : PipView { override fun setPipParams() = setPipParamsResult() override fun enterPipMode(): Boolean = enterPipModeResult() override fun hangUp() = handUpResult() + override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation) } diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt index a19663f50f..eb6b401667 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -299,10 +299,10 @@ private fun Content( } /** - * Ensure that the string does not contain any new line characters, which can happen when pasting values. + * Ensure that the string does not contain any line separator, which can happen when pasting values. */ private fun String.sanitize(): String { - return replace("\n", "") + return filterNot { it == '\n' || it == '\r' } } @PreviewsDayNight diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt new file mode 100644 index 0000000000..bfb9fb538a --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.api + +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features. + */ +fun interface ClientBuilderEnterpriseHook { + suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt new file mode 100644 index 0000000000..e6d5c9aac1 --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.api + +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * A hook that can be used to customize the [MatrixClient] for enterprise features. + */ +fun interface ClientEnterpriseHook { + suspend operator fun invoke(client: MatrixClient) +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 7d23ad0ea1..2cb4a356be 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -10,9 +10,8 @@ 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 io.element.android.libraries.wellknown.api.ElementWellKnown import kotlinx.coroutines.flow.Flow /** @@ -21,9 +20,6 @@ import kotlinx.coroutines.flow.Flow * On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves. */ interface EnterpriseService { - /** Whether this build is an enterprise one; `false` for the public app. */ - val isEnterpriseBuild: Boolean - /** * Whether the given session belongs to an enterprise deployment, which can be true even on a standard build. * @@ -35,23 +31,31 @@ 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 /** - * The homeservers the user is allowed to sign in to; an empty list, or one containing [ANY_ACCOUNT_PROVIDER], means no restriction. + * Returns the list of homeservers the user is allowed to sign in to. + * + * If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver. */ - fun defaultHomeserverList(): List + fun homeserverAllowList(): List /** - * Whether the user is allowed to sign in to a given homeserver, according to [defaultHomeserverList]. + * Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList]. * * @param homeserverUrl the server the user is trying to use. */ suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean + /** + * Whether the given homeserver enforces the use of Element Pro or a derived app. + * + * @param serverName the homeserver to check. + */ + suspend fun isElementProEnforced(serverName: String): Boolean + /** * Override the brand color. * @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session. @@ -94,26 +98,13 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? - /** - * Gets the overridden Element Well-Known data if it has been set, or null if not set. - */ - fun overriddenElementWellKnown(): ElementWellKnown? - - /** - * 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 = "*" } } fun EnterpriseService.canConnectToAnyHomeserver(): Boolean { - return defaultHomeserverList().let { + return homeserverAllowList().let { it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER) } } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt new file mode 100644 index 0000000000..83591ea7bd --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.api + +/** + * A use case to check if this build of the app is an enterprise build. + */ +fun interface IsEnterpriseBuild { + /** + * Returns `true` if this build of the app is an enterprise build, `false` otherwise. + */ + operator fun invoke(): Boolean +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index 84067ce56f..94f455f2ff 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -14,6 +14,9 @@ package io.element.android.features.enterprise.api * [init] must have run before the other members return meaningful answers. */ interface SessionEnterpriseService { + /** Loads the deployment configuration of this session; to be called once when the session starts. */ + suspend fun init() + /** Whether Element Call may be used on this session, which a deployment can turn off. */ suspend fun isElementCallAvailable(): Boolean @@ -26,7 +29,4 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean - - /** Loads the deployment configuration of this session; to be called once when the session starts. */ - suspend fun init() } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt new file mode 100644 index 0000000000..9bfd3c940a --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt @@ -0,0 +1,15 @@ +/* + * 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.remoteconfig + +/** + * A provider of a runtime-fetched MapTiler configuration, which can be used to override the default MapTiler configuration. + */ +fun interface CustomMapTilerConfigProvider { + suspend fun get(): Result +} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt similarity index 57% rename from libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt rename to features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt index 88a89e7a49..3e8b8b14e1 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt @@ -1,29 +1,18 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.wellknown.api +package io.element.android.features.enterprise.api.remoteconfig -data class ElementWellKnown( - val registrationHelperUrl: String?, - val enforceElementPro: Boolean?, - val rageshakeUrl: String?, - val brandColor: String?, - val notificationSound: String?, - val identityProviderAppScheme: String?, - val customRecoveryPassphrase: CustomRecoveryPassphrase?, - val contentScannerUrl: String?, - val forceDisableE2EE: Boolean?, - val mapTilerConfig: MapTilerConfig?, -) +import androidx.compose.runtime.Immutable /** * A `MapTiler` configuration received from the server. This configuration is used to display maps in the app. */ +@Immutable data class MapTilerConfig( /** The API key to use with MapTiler. This is always not null, but can be empty/blank. */ val apiKey: String, diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt deleted file mode 100644 index c9adf21eec..0000000000 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt +++ /dev/null @@ -1,17 +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.api.remoteconfig - -import io.element.android.libraries.wellknown.api.ElementWellKnown - -/** - * Typealias for the well-known configuration used for enterprise features. - * - * This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module. - */ -typealias RemoteEnterpriseConfig = ElementWellKnown diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt deleted file mode 100644 index 4507b1a72a..0000000000 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt +++ /dev/null @@ -1,35 +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.api.remoteconfig - -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -/** - * Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source. - */ -interface RemoteEnterpriseConfigProvider { - /** - * Returns the configuration of a session, served from the cache when possible. - * A stale entry is reported as outdated while a refresh runs in the background, so this rarely waits on the network. - * - * @param sessionId the session whose configuration is requested. - */ - suspend fun get(sessionId: SessionId): WellknownRetrieverResult - - /** - * Creates a provider, which needs a fetcher because the configuration is read over HTTP. - */ - interface Factory { - /** - * @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works. - */ - fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider - } -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt similarity index 59% rename from libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt rename to features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt index 98af981a66..0cdcdebe0b 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt @@ -1,45 +1,44 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.wellknown.api +package io.element.android.features.enterprise.api.remoteconfig -sealed interface WellknownRetrieverResult { +sealed interface RemoteEnterpriseConfigResult { /** * Well-known data has been successfully retrieved. */ - data class Success(val data: T) : WellknownRetrieverResult + data class Success(val data: T) : RemoteEnterpriseConfigResult /** * Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched. */ - data class Outdated(val data: T) : WellknownRetrieverResult + data class Outdated(val data: T) : RemoteEnterpriseConfigResult /** * Well-known data is not found (file does not exist server side, we got a 404). */ - data object NotFound : WellknownRetrieverResult + data object NotFound : RemoteEnterpriseConfigResult /** * Any other error. */ - data class Error(val exception: Exception) : WellknownRetrieverResult + data class Error(val exception: Exception) : RemoteEnterpriseConfigResult fun dataOrNull(): T? = when (this) { is Success -> data is Outdated -> data is Error -> null - NotFound -> null + is NotFound -> null } fun upToDateDataOrNull(): T? = when (this) { is Success -> data is Outdated -> null is Error -> null - NotFound -> null + is NotFound -> null } } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt new file mode 100644 index 0000000000..ee22e4399d --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS. + */ +@ContributesBinding(AppScope::class) +class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { + override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + // No modification + return clientBuilder + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt new file mode 100644 index 0000000000..b0c0152d94 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.ClientEnterpriseHook +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * Default implementation of [ClientEnterpriseHook] that does nothing for FOSS. + */ +@ContributesBinding(AppScope::class) +class DefaultClientEnterpriseHook : ClientEnterpriseHook { + override suspend fun invoke(client: MatrixClient) { + // No-op + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index c75a2cb527..bb10550369 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -14,20 +14,18 @@ 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 io.element.android.libraries.wellknown.api.ElementWellKnown import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) class DefaultEnterpriseService : EnterpriseService { - override val isEnterpriseBuild = false - override suspend fun isEnterpriseUser(sessionId: SessionId) = false - override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url - override fun defaultHomeserverList(): List = emptyList() + override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url + override fun homeserverAllowList(): List = emptyList() override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true + override suspend fun isElementProEnforced(serverName: String): Boolean = false override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit @@ -47,8 +45,4 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null - - override fun overriddenElementWellKnown(): ElementWellKnown? = null - - override fun essConfigEndpointUrl(domain: String): String? = null } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt new file mode 100644 index 0000000000..73da4d5d03 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.IsEnterpriseBuild + +@ContributesBinding(AppScope::class) +class DefaultIsEnterpriseBuild : IsEnterpriseBuild { + override fun invoke(): Boolean = false +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt new file mode 100644 index 0000000000..2b136d3739 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl.remoteconfig + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultCustomMapTilerConfigProvider : CustomMapTilerConfigProvider { + override suspend fun get(): Result = Result.success(null) +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt deleted file mode 100644 index fbc5cd5827..0000000000 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt +++ /dev/null @@ -1,30 +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.remoteconfig - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -@ContributesBinding(AppScope::class) -class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider { - override suspend fun get(sessionId: SessionId): WellknownRetrieverResult { - return WellknownRetrieverResult.NotFound - } - - @ContributesBinding(AppScope::class) - class Factory : RemoteEnterpriseConfigProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider { - return DefaultRemoteEnterpriseConfigProvider() - } - } -} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt deleted file mode 100644 index 4d4a800de9..0000000000 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.enterprise.impl.scanner - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider - -/** - * Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL. - */ -@ContributesBinding(AppScope::class) -class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { - override suspend fun getContentScannerUrl(sessionId: SessionId): Result = Result.success(null) - - @ContributesBinding(AppScope::class) - class Factory : ContentScannerUrlProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider { - return DefaultContentScannerUrlProvider() - } - } -} diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index 2bee3f8b4d..474990c6d9 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -19,15 +19,9 @@ import org.junit.Test class DefaultEnterpriseServiceTest { @Test - fun `isEnterpriseBuild is false`() { + fun homeserverWhitelist() { val defaultEnterpriseService = DefaultEnterpriseService() - assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse() - } - - @Test - fun `defaultHomeserverList should return empty list`() { - val defaultEnterpriseService = DefaultEnterpriseService() - assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty() + assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() } @Test diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 858c46e3bf..a8efcb5873 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -12,9 +12,8 @@ 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.libraries.wellknown.api.ElementWellKnown import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.Flow @@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class FakeEnterpriseService( - override val isEnterpriseBuild: Boolean = false, private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val defaultHomeserverListResult: () -> List = { emptyList() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, @@ -32,9 +30,8 @@ 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 overrideWellKnownResult: () -> ElementWellKnown? = { 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) private val semanticColorsState = MutableStateFlow(initialSemanticColors) @@ -43,11 +40,11 @@ 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 defaultHomeserverList(): List { + override fun homeserverAllowList(): List { return defaultHomeserverListResult() } @@ -55,6 +52,10 @@ class FakeEnterpriseService( isAllowedToConnectToHomeserverResult(homeserverUrl) } + override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask { + isElementProEnforcedResult(serverName) + } + override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask { overrideBrandColorResult(sessionId, brandColor) } @@ -83,12 +84,4 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } - - override fun overriddenElementWellKnown(): ElementWellKnown? { - return overrideWellKnownResult() - } - - override fun essConfigEndpointUrl(domain: String): String { - return essConfigEndpointUrlResult(domain) - } } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt deleted file mode 100644 index 2c57dbb530..0000000000 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt +++ /dev/null @@ -1,21 +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.test.remoteconfig - -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -class FakeRemoteEnterpriseConfigProvider( - private val getResult: (SessionId) -> WellknownRetrieverResult = { WellknownRetrieverResult.NotFound } -) : RemoteEnterpriseConfigProvider { - override suspend fun get(sessionId: SessionId): WellknownRetrieverResult { - return getResult(sessionId) - } -} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt deleted file mode 100644 index a49bbba279..0000000000 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt +++ /dev/null @@ -1,17 +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.test.remoteconfig - -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.libraries.matrix.api.UrlContentFetcher - -class FakeRemoteEnterpriseConfigProviderFactory( - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), -) : RemoteEnterpriseConfigProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider -} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt new file mode 100644 index 0000000000..fd429eada5 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt @@ -0,0 +1,22 @@ +/* + * 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.test.remoteconfig + +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig + +fun aMapTilerConfig( + apiKey: String = "test_api_key", + baseUrl: String? = null, + lightStyleId: String? = null, + darkStyleId: String? = null, +) = MapTilerConfig( + apiKey = apiKey, + baseUrl = baseUrl, + lightStyleId = lightStyleId, + darkStyleId = darkStyleId, +) diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1a90245611..1c6a0a906e 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom @@ -23,7 +24,7 @@ interface HomeEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) + fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) fun navigateToCreateRoom() fun navigateToCreateSpace() fun navigateToSettings() diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 00f285e3f4..20f27f67bd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.utils.DelayedVisibility import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope @@ -183,6 +184,7 @@ class HomeFlowNode( fun navigateToRoom( roomId: RoomId, + eventId: EventId?, ) { if (!loadingJoinedRoomJob.value.isUninitialized()) { Timber.w("Already loading a room, ignoring navigateToRoom for $roomId") @@ -195,7 +197,7 @@ class HomeFlowNode( }.fold( onSuccess = { joinedRoom -> if (isActive) { - callback.navigateToRoom(roomId, joinedRoom) + callback.navigateToRoom(roomId = roomId, eventId = eventId, joinedRoom = joinedRoom) loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job) // Wait a bit before resetting the state to avoid allowing to open several rooms delay(200.milliseconds) @@ -205,7 +207,7 @@ class HomeFlowNode( onFailure = { // If the operation wasn't cancelled, navigate without the room, using the room id if (it !is CancellationException) { - callback.navigateToRoom(roomId, null) + callback.navigateToRoom(roomId = roomId, eventId = null, joinedRoom = null) } loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job) // Wait a bit before resetting the state to avoid allowing to open several rooms @@ -233,7 +235,7 @@ class HomeFlowNode( acceptDeclineInviteView = { acceptDeclineInviteView.Render( state = state.roomListState.acceptDeclineInviteState, - onAcceptInviteSuccess = ::navigateToRoom, + onAcceptInviteSuccess = { navigateToRoom(roomId = it, eventId = null) }, onDeclineInviteSuccess = { }, modifier = Modifier ) @@ -269,6 +271,11 @@ class HomeFlowNode( parentNode = this, buildContext = buildContext, inviteData = navTarget.inviteData, + callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() { + backstack.pop() + } + }, ) } is NavTarget.SelectNewOwnersWhenLeavingRoom -> { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 734c3ecb69..024d013871 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -55,6 +55,8 @@ import io.element.android.features.home.impl.roomlist.RoomListContextMenu import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu import io.element.android.features.home.impl.roomlist.RoomListEvent import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchEvent +import io.element.android.features.home.impl.search.GlobalSearchView import io.element.android.features.home.impl.search.RoomListSearchView import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -73,6 +75,7 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -80,7 +83,7 @@ import kotlinx.coroutines.launch @Composable fun HomeView( homeState: HomeState, - onRoomClick: (RoomId) -> Unit, + onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, @@ -122,22 +125,34 @@ fun HomeView( state = homeState, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, - onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, + onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) }, onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() }, onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, ) - // This overlaid view will only be visible when state.displaySearchResults is true - RoomListSearchView( - state = state.searchState, - eventSink = state.eventSink, - hideInvitesAvatars = state.hideInvitesAvatars, - onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, - modifier = Modifier - .fillMaxSize() - .background(ElementTheme.colors.bgCanvasDefault) - ) + + if (state.globalSearchState.isEnabled) { + GlobalSearchView( + state = state.globalSearchState, + onSelectSearchResult = { roomId, eventId -> if (firstThrottler.canHandle()) onRoomClick(roomId, eventId) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + ) + } else { + // This overlaid view will only be visible when state.displaySearchResults is true + RoomListSearchView( + state = state.searchState, + eventSink = state.eventSink, + hideInvitesAvatars = state.hideInvitesAvatars, + onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault) + ) + } + acceptDeclineInviteView() } } @@ -186,8 +201,18 @@ private fun HomeScaffold( selectedNavigationItem = state.currentHomeNavigationBarItem, currentUserAndNeighbors = state.currentUserAndNeighbors, showAvatarIndicator = state.showAvatarIndicator, - areSearchResultsDisplayed = roomListState.searchState.isSearchActive, - onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) }, + areSearchResultsDisplayed = if (roomListState.globalSearchState.isEnabled) { + roomListState.globalSearchState.isSearchActive + } else { + roomListState.searchState.isSearchActive + }, + onToggleSearch = { + if (roomListState.globalSearchState.isEnabled) { + roomListState.globalSearchState.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } else { + roomListState.eventSink(RoomListEvent.ToggleSearchResults) + } + }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, onAccountSwitch = { @@ -349,7 +374,7 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) state: HomeState) = ElementPreview { HomeView( homeState = state, - onRoomClick = {}, + onRoomClick = { _, _ -> }, onSettingsClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, @@ -369,7 +394,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta internal fun HomeViewA11yPreview() = ElementPreview { HomeView( homeState = aHomeState(), - onRoomClick = {}, + onRoomClick = { _, _ -> }, onSettingsClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt index 1eeba4fff7..6b744ca4df 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt @@ -15,6 +15,8 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.roomlist.RoomListPresenter import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchPresenter +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter @@ -36,4 +38,7 @@ interface RoomListModule { @Binds fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter + + @Binds + fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index fe09bd7cd1..1c21177988 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -85,6 +86,7 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, + private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService @@ -97,6 +99,7 @@ class RoomListPresenter( val searchState = searchPresenter.present() val spaceFiltersState = spaceFiltersPresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + val globalSearchState = globalSearchPresenter.present() LaunchedEffect(Unit) { roomListDataSource.launchIn(this) @@ -177,6 +180,7 @@ class RoomListPresenter( leaveRoomState = leaveRoomState, filtersState = filtersState, searchState = searchState, + globalSearchState = globalSearchState, spaceFiltersState = spaceFiltersState, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index e184a5a57f..4164c56e9d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist import androidx.compose.runtime.Immutable import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState @@ -27,6 +28,7 @@ data class RoomListState( val leaveRoomState: LeaveRoomState, val filtersState: RoomListFiltersState, val searchState: RoomListSearchState, + val globalSearchState: GlobalSearchState, val spaceFiltersState: SpaceFiltersState, val contentState: RoomListContentState, val acceptDeclineInviteState: AcceptDeclineInviteState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStatePreviewParam.kt index a0db1ee0a3..04f612870f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStatePreviewParam.kt @@ -16,7 +16,9 @@ import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.aRoomListRoomSummary import io.element.android.features.home.impl.model.anInviteSender +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aGlobalSearchState import io.element.android.features.home.impl.search.aRoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState @@ -41,7 +43,10 @@ open class RoomListStatePreviewParam : PreviewParameterProvider { aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), aRoomListState(contentState = anEmptyContentState()), aRoomListState(contentState = aSkeletonContentState()), - aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), + aRoomListState( + searchState = aRoomListSearchState(isSearchActive = true, query = "Test"), + globalSearchState = aGlobalSearchState(isEnabled = false), + ), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)), aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))), aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), @@ -53,6 +58,7 @@ internal fun aRoomListState( declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden, leaveRoomState: LeaveRoomState = aLeaveRoomState(), searchState: RoomListSearchState = aRoomListSearchState(), + globalSearchState: GlobalSearchState = aGlobalSearchState(), filtersState: RoomListFiltersState = aRoomListFiltersState(), spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(), contentState: RoomListContentState = aRoomsContentState(), @@ -66,6 +72,7 @@ internal fun aRoomListState( leaveRoomState = leaveRoomState, filtersState = filtersState, searchState = searchState, + globalSearchState = globalSearchState, spaceFiltersState = spaceFiltersState, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt new file mode 100644 index 0000000000..76d9147e94 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface GlobalSearchEvent { + object ClearQuery : GlobalSearchEvent + object ToggleSearchVisibility : GlobalSearchEvent + data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent + data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt new file mode 100644 index 0000000000..6e153344c9 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.search.MessageSearch +import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.toPlainText +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration.Companion.milliseconds + +@Inject +class GlobalSearchPresenter( + private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, + private val messageSearchService: MessageSearchService, + private val featureFlagService: FeatureFlagService, + private val latestEventFormatter: RoomLatestEventFormatter, + private val dateFormatter: DateFormatter, + private val fileSizeFormatter: FileSizeFormatter, + private val permalinkParser: PermalinkParser, + private val coroutineDispatchers: CoroutineDispatchers, + private val matrixClient: MatrixClient, +) : Presenter { + @Composable + override fun present(): GlobalSearchState { + val coroutineScope = rememberCoroutineScope() + val isEnabled by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } + } + val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) } + val queryState = rememberTextFieldState() + var isSearchActive by remember { mutableStateOf(false) } + var searchResults: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) } + var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } + val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } + + LaunchedEffect(queryState.text) { + // Add a delay to avoid performing too many searches in a short period of time, which can be expensive + delay(200.milliseconds) + + searchResults = if (queryState.text.isNotEmpty()) { + AsyncData.Loading(prevData = searchResults.dataOrNull()) + } else { + AsyncData.Uninitialized + } + + launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) } + launch { + currentMessageSearch.setQuery(queryState.text.toString()) + .onFailure { Timber.e(it, "Could not set query for message search") } + } + } + + LaunchedEffect(currentTarget) { + if (queryState.text.isNotEmpty()) { + searchResults = AsyncData.Loading() + } + + when (currentTarget) { + GlobalSearchTarget.ROOMS -> { + roomListSearchDataSource.roomSummaries.collectLatest { results -> + Timber.d("Room list search found ${results.size} items") + // The room list data source always returns some values, even if the query is empty, + // so we only want to update the search results if the query is not empty + searchResults = if (queryState.text.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } else { + AsyncData.Uninitialized + } + } + } + GlobalSearchTarget.MESSAGES -> { + Timber.d("Message search found ${currentMessageSearch.results.value.size} items") + combine( + currentMessageSearch.results, + currentMessageSearch.paginationState, + ) { results, paginationState -> + Pair(results, paginationState) + }.collectLatest { (results, paginationState) -> + Timber.d("Message search found ${results.size} items, pagination state: $paginationState") + + // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each + // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. + val mappedResults = results.map { result -> + async(coroutineDispatchers.computation) { + val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null } + val formattedTimestamp = dateFormatter.format( + timestamp = result.timestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true + ) + + if (!result.content.isMediaContent()) { + mapMessageContent(result, roomInfo, formattedTimestamp) + } else { + mapMediaContent(result, roomInfo, formattedTimestamp) + } + } + } + .awaitAll() + .mapNotNull { it } + + searchResults = when { + queryState.text.isEmpty() -> AsyncData.Uninitialized + mappedResults.isNotEmpty() -> { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) + } + paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) + } + paginationState is MessageSearchPaginationState.Loading -> { + AsyncData.Loading(prevData = searchResults.dataOrNull()) + } + else -> { + AsyncData.Uninitialized + } + } + } + } + } + } + + fun handleEvent(event: GlobalSearchEvent) { + when (event) { + GlobalSearchEvent.ClearQuery -> { + queryState.clearText() + } + GlobalSearchEvent.ToggleSearchVisibility -> { + isSearchActive = !isSearchActive + queryState.clearText() + } + is GlobalSearchEvent.UpdateVisibleRange -> { + when (currentTarget) { + GlobalSearchTarget.ROOMS -> coroutineScope.launch { + // TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe + // to the room info for each room in the search results. + roomListSearchDataSource.updateVisibleRange(event.range) + } + GlobalSearchTarget.MESSAGES -> coroutineScope.launch { + val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0 + val currentPaginationState = currentMessageSearch.paginationState.value + val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached + if (event.range.last >= currentCount - 10 && canPaginate) { + currentMessageSearch.paginate() + } + } + } + } + is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target + } + } + + return GlobalSearchState( + isEnabled = isEnabled, + isSearchActive = isSearchActive, + queryState = queryState, + currentTarget = currentTarget, + results = searchResults, + eventSink = ::handleEvent, + ) + } + + private fun mapMessageContent( + result: MessageSearchResult, + roomInfo: RoomInfo, + formattedTimestamp: String, + ): MessageSearchResultItem.Message? { + val body = latestEventFormatter.format( + latestEvent = LatestEventValue.Remote( + timestamp = result.timestamp, + content = result.content, + senderId = result.senderId, + senderProfile = result.senderProfile, + isOwn = matrixClient.isMe(result.senderId), + ), + isDmRoom = false, + ) + return MessageSearchResultItem.Message( + messageSearchResult = result, + body = body?.toString() ?: "", + formattedTimestamp = formattedTimestamp, + roomInfo = roomInfo, + ) + } + + private fun mapMediaContent( + result: MessageSearchResult, + roomInfo: RoomInfo, + formattedTimestamp: String, + ): MessageSearchResultItem.Media? { + val messageType = when (val content = result.content) { + is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment + else -> return null + } + + val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename) + + val thumbnailType = when (messageType) { + is ImageMessageType -> AttachmentThumbnailType.Image + is VideoMessageType -> AttachmentThumbnailType.Video + is AudioMessageType -> AttachmentThumbnailType.Audio + is VoiceMessageType -> AttachmentThumbnailType.Voice + is FileMessageType -> AttachmentThumbnailType.File + is StickerMessageType -> AttachmentThumbnailType.Image + } + val thumbnailSource = when (messageType) { + is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source + is VideoMessageType -> messageType.info?.thumbnailSource + is AudioMessageType -> null + is VoiceMessageType -> null + is FileMessageType -> null + is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source + } + val blurhash = when (messageType) { + is ImageMessageType -> messageType.info?.blurhash + is VideoMessageType -> messageType.info?.blurhash + is AudioMessageType -> null + is VoiceMessageType -> null + is FileMessageType -> null + is StickerMessageType -> messageType.info?.blurhash + } + + val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase() + val formattedSize = when (messageType) { + is ImageMessageType -> messageType.info?.size + is VideoMessageType -> messageType.info?.size + is AudioMessageType -> messageType.info?.size + is VoiceMessageType -> messageType.info?.size + is FileMessageType -> messageType.info?.size + is StickerMessageType -> messageType.info?.size + } + ?.let(fileSizeFormatter::format) + + val mediaContent = MediaSearchResultContent( + filename = messageType.filename, + extension = extension, + caption = caption, + formattedSize = formattedSize, + thumbnailSource = thumbnailSource, + thumbnailType = thumbnailType, + blurhash = blurhash + ) + + return MessageSearchResultItem.Media( + messageSearchResult = result, + mediaContent = mediaContent, + formattedTimestamp = formattedTimestamp, + roomInfo = roomInfo, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt new file mode 100644 index 0000000000..d623fd5594 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class GlobalSearchState( + val isEnabled: Boolean, + val isSearchActive: Boolean, + val queryState: TextFieldState, + val currentTarget: GlobalSearchTarget, + val results: AsyncData, + val eventSink: (GlobalSearchEvent) -> Unit +) + +@Immutable +enum class GlobalSearchTarget { + ROOMS, + MESSAGES, +} + +@Immutable +sealed interface GlobalSearchResults { + data class RoomListResults(val results: ImmutableList) : GlobalSearchResults + data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults + + fun isEmpty(): Boolean = when (this) { + is RoomListResults -> results.isEmpty() + is MessageSearchResults -> results.isEmpty() + } +} + +sealed interface MessageSearchResultItem { + val roomId: RoomId + val eventId: EventId + val senderId: UserId + val senderName: String + val roomInfo: RoomInfo + val formattedTimestamp: String + + data class Message( + val messageSearchResult: MessageSearchResult, + val body: String, + override val roomInfo: RoomInfo, + override val formattedTimestamp: String, + ) : MessageSearchResultItem { + override val roomId: RoomId = messageSearchResult.roomId + override val eventId: EventId = messageSearchResult.eventId + override val senderId: UserId = messageSearchResult.senderId + override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) + } + + data class Media( + val messageSearchResult: MessageSearchResult, + val mediaContent: MediaSearchResultContent, + override val roomInfo: RoomInfo, + override val formattedTimestamp: String, + ) : MessageSearchResultItem { + override val roomId: RoomId = messageSearchResult.roomId + override val eventId: EventId = messageSearchResult.eventId + override val senderId: UserId = messageSearchResult.senderId + override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) + } +} + +data class MediaSearchResultContent( + val filename: String, + val extension: String?, + val caption: String?, + val formattedSize: String?, + val thumbnailSource: MediaSource?, + val thumbnailType: AttachmentThumbnailType, + val blurhash: String?, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt new file mode 100644 index 0000000000..73487c5069 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.CallIntentConsensus +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList + +class GlobalSearchStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Not enabled, no preview + aGlobalSearchState(isEnabled = false), + aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Loading(), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Success( + GlobalSearchResults.RoomListResults( + results = aRoomListRoomSummaryList().mapIndexed { index, summary -> + summary.copy(name = "Room with Query #${index + 1}") + }.toPersistentList() + ) + ), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( + MessageSearchResultItem.Message( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), + body = "A message with Query", + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + MessageSearchResultItem.Media( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), + mediaContent = MediaSearchResultContent( + filename = "file.png", + extension = "PNG", + caption = "A caption containing Query", + formattedSize = "1 MB", + thumbnailSource = MediaSource("https://example.com/thumbnail.png"), + thumbnailType = AttachmentThumbnailType.Image, + blurhash = null, + ), + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + ))), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), + ), + ) +} + +fun aRoomInfo( + id: RoomId = RoomId("!roomId:server.org"), + name: String? = "A room", + rawName: String? = "A room raw name", + topic: String? = "A room topic", + avatarUrl: String? = "https://example.com/avatar.png", + isPublic: Boolean = true, + isDirect: Boolean = false, + isEncrypted: Boolean = false, + joinRule: JoinRule? = JoinRule.Public, + isSpace: Boolean = false, + successorRoom: SuccessorRoom? = null, + isFavorite: Boolean = false, + canonicalAlias: RoomAlias? = null, + alternativeAliases: List = emptyList(), + currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, + inviter: RoomMember? = null, + activeMembersCount: Long = 2, + invitedMembersCount: Long = 1, + joinedMembersCount: Long = 1, + highlightCount: Long = 0, + notificationCount: Long = 0, + userDefinedNotificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + roomPowerLevels: RoomPowerLevels? = RoomPowerLevels( + values = RoomPowerLevelsValues( + ban = 0, + invite = 0, + kick = 0, + eventsDefault = 0, + stateDefault = 0, + redactEvents = 0, + roomName = 0, + roomAvatar = 0, + roomTopic = 0, + spaceChild = 0, + beacon = 0, + beaconInfo = 0 + ), + users = persistentMapOf(), + ), + activeRoomCallParticipants: List = emptyList(), + heroes: List = emptyList(), + pinnedEventIds: List = emptyList(), + roomCreators: List = emptyList(), + isMarkedUnread: Boolean = false, + numUnreadMessages: Long = 0, + numUnreadNotifications: Long = 0, + numUnreadMentions: Long = 0, + historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, + roomVersion: String? = "11", + privilegedCreatorRole: Boolean = false, + isLowPriority: Boolean = false, + activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None, + isDm: Boolean = false, + fullyReadEventId: EventId? = null, +) = RoomInfo( + id = id, + name = name, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + isPublic = isPublic, + isDirect = isDirect, + isEncrypted = isEncrypted, + joinRule = joinRule, + isSpace = isSpace, + successorRoom = successorRoom, + isFavorite = isFavorite, + canonicalAlias = canonicalAlias, + alternativeAliases = alternativeAliases.toImmutableList(), + currentUserMembership = currentUserMembership, + inviter = inviter, + activeMembersCount = activeMembersCount, + invitedMembersCount = invitedMembersCount, + joinedMembersCount = joinedMembersCount, + highlightCount = highlightCount, + notificationCount = notificationCount, + userDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = hasRoomCall, + roomPowerLevels = roomPowerLevels, + activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), + heroes = heroes.toImmutableList(), + pinnedEventIds = pinnedEventIds.toImmutableList(), + creators = roomCreators.toImmutableList(), + isMarkedUnread = isMarkedUnread, + numUnreadMessages = numUnreadMessages, + numUnreadNotifications = numUnreadNotifications, + numUnreadMentions = numUnreadMentions, + historyVisibility = historyVisibility, + roomVersion = roomVersion, + privilegedCreatorRole = privilegedCreatorRole, + isLowPriority = isLowPriority, + activeCallIntentConsensus = activeCallIntentConsensus, + isDm = isDm, + fullyReadEventId = fullyReadEventId, +) + +fun aMessageContent( + body: String = "body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + threadInfo: EventThreadInfo? = null, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + threadInfo = threadInfo, + type = messageType +) + +internal fun aMessageSearchResult( + roomId: RoomId = RoomId("!roomId:server.org"), + eventId: EventId = EventId("\$eventId:server.org"), + senderId: UserId = UserId("@user:server.org"), + senderProfile: ProfileDetails = aProfileDetailsReady(), + content: MessageContent = aMessageContent(), + timestamp: Long = 0L, +) = MessageSearchResult( + roomId = roomId, + eventId = eventId, + senderId = senderId, + senderProfile = senderProfile, + content = content, + timestamp = timestamp, +) + +internal fun aGlobalSearchState( + isEnabled: Boolean = true, + isSearchActive: Boolean = false, + queryState: TextFieldState = TextFieldState(), + currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, + results: AsyncData = AsyncData.Uninitialized, + eventSink: (GlobalSearchEvent) -> Unit = {}, +) = GlobalSearchState( + isEnabled = isEnabled, + isSearchActive = isSearchActive, + queryState = queryState, + currentTarget = currentTarget, + results = results, + eventSink = eventSink +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt new file mode 100644 index 0000000000..d5a0460f06 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.theme.components.FilledTextField +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding +import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun GlobalSearchView( + state: GlobalSearchState, + onSelectSearchResult: (RoomId, EventId?) -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = state.isSearchActive) { + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + + AnimatedVisibility( + visible = state.isSearchActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column(modifier = modifier) { + GlobalSearchContent( + state = state, + onBackButtonClick = { + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + }, + onSelectSearchResult = onSelectSearchResult, + ) + } + } +} + +@Composable +private fun GlobalSearchContent( + state: GlobalSearchState, + onBackButtonClick: () -> Unit, + onSelectSearchResult: (RoomId, EventId?) -> Unit, +) { + val borderColor = MaterialTheme.colorScheme.tertiary + val strokeWidth = 1.dp + + val drawBottomLineModifier = Modifier.drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth.value + ) + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { BackButton(onClick = onBackButtonClick) }, + title = { + // The stateSaver will keep the selection state when returning to this UI + val focusRequester = remember { FocusRequester() } + val searchLabel = stringResource(CommonStrings.action_search) + FilledTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .semantics { contentDescription = searchLabel }, + state = state.queryState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + trailingIcon = if (state.queryState.text.isNotEmpty()) { + @Composable { + IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.a11y_clear_search_field) + ) + } + } + } else { + null + }, + ) + + LaunchedEffect(Unit) { + if (!focusRequester.restoreFocusedChild()) { + focusRequester.requestFocus() + } + focusRequester.saveFocusedChild() + } + } + ) + }, + contentWindowInsets = scaffoldScrollableContentInsets, + ) { padding -> + Column( + modifier = Modifier.padding(padding), + ) { + SingleChoiceSegmentedButtonRow( + modifier = drawBottomLineModifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) + ) { + SegmentedButton( + index = 0, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.ROOMS, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) }, + text = stringResource(R.string.search_section_chats), + ) + + SegmentedButton( + index = 1, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.MESSAGES, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) }, + text = stringResource(R.string.search_section_messages), + ) + } + + val lazyListState = rememberLazyListState() + OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> + state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange)) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = lazyColumnContentPadding, + state = lazyListState, + ) { + val results = state.results.dataOrNull() + when { + state.results.isUninitialized() -> startSearching() + state.results.isLoading() -> loading() + results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString()) + results is GlobalSearchResults.RoomListResults -> roomListResults( + results = results.results, + onRoomClick = { roomId -> onSelectSearchResult(roomId, null) }, + ) + results is GlobalSearchResults.MessageSearchResults -> messageSearchResults( + results = results.results, + onSearchResultSelected = onSelectSearchResult, + ) + } + } + } + } +} + +private fun LazyListScope.startSearching() { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + title = stringResource(R.string.start_searching_title), + subTitle = stringResource(R.string.start_searching_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), + ) + } +} + +private fun LazyListScope.loading() { + item { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + +private fun LazyListScope.emptySearchResults(query: String) { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + title = stringResource(R.string.search_no_results_title), + subTitle = stringResource(R.string.search_no_results_subtitle, query), + iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), + ) + } +} + +private fun LazyListScope.roomListResults( + results: ImmutableList, + onRoomClick: (RoomId) -> Unit, +) { + itemsIndexed( + items = results, + key = { _, roomSummary -> roomSummary.roomId }, + ) { index, result -> + Row( + modifier = Modifier + .niceClickable(onClick = { onRoomClick(result.roomId) }) + .fillMaxWidth() + .addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem), + avatarType = AvatarType.Room( + heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(), + isTombstoned = result.isTombstoned, + ), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = result.name ?: result.roomId.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + + if (result.isDm) { + val userId = result.heroes.firstOrNull()?.id + userId?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } else { + result.canonicalAlias?.let { + Text( + text = it.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } +} + +private fun LazyListScope.messageSearchResults( + results: ImmutableList, + onSearchResultSelected: (RoomId, EventId?) -> Unit, +) { + if (results.isNotEmpty()) { + item { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + text = stringResource(R.string.search_from_your_messages_header), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + itemsIndexed( + items = results, + contentType = { _, messageSearchResult -> messageSearchResult::class.java }, + key = { _, messageSearchResult -> messageSearchResult.eventId }, + ) { index, result -> + val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) + when (result) { + is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( + modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), + avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), + avatarType = AvatarType.Room( + heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(), + isTombstoned = result.roomInfo.successorRoom != null + ), + roomName = result.roomInfo.name ?: result.roomInfo.id.value, + body = result.body, + formattedTimestamp = result.formattedTimestamp, + ) + is MessageSearchResultItem.Media -> { + MediaMessageSearchResultItemView( + modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), + avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), + avatarType = AvatarType.Room( + heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(), + isTombstoned = result.roomInfo.successorRoom != null + ), + roomName = result.roomInfo.name ?: result.roomInfo.id.value, + content = result.mediaContent, + formattedTimestamp = result.formattedTimestamp, + ) + } + } + } +} + +@Composable +private fun TextMessageSearchResultItemView( + avatarData: AvatarData, + avatarType: AvatarType, + roomName: String, + body: String, + formattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + modifier = Modifier.weight(1f), + text = roomName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = formattedTimestamp, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Text( + text = body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun MediaMessageSearchResultItemView( + avatarData: AvatarData, + avatarType: AvatarType, + roomName: String, + content: MediaSearchResultContent, + formattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + modifier = Modifier.weight(1f), + text = roomName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = formattedTimestamp, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + + content.caption?.let { + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Row( + modifier = Modifier + .fillMaxWidth(1f) + .background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AttachmentThumbnail( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)), + info = AttachmentThumbnailInfo( + type = content.thumbnailType, + thumbnailSource = content.thumbnailSource, + textContent = null, + blurHash = content.blurhash, + ), + thumbnailSize = 36.dp.roundToPx().toLong(), + backgroundColor = ElementTheme.colors.bgCanvasDefault, + ) + + Column { + Text( + text = content.filename, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + content.extension?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + content.formattedSize?.let { + Text( + text = "($it)", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } + } +} + +private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier { + if (isLastItem) return this + + return this.drawBehind { + val strokeWidthPx = 1.dp.toPx() + drawLine( + color = color, + start = Offset(0f, size.height - strokeWidthPx), + end = Offset(size.width, size.height - strokeWidthPx), + strokeWidth = strokeWidthPx, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun GlobalSearchViewPreview( + @PreviewParameter(GlobalSearchStatePreviewParam::class) state: GlobalSearchState, +) { + ElementPreview { + GlobalSearchView( + state = state, + onSelectSearchResult = { _, _ -> }, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt index 03fcb5520f..df7ed0dd37 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt @@ -46,6 +46,8 @@ class RoomListSearchDataSource( coroutineScope = coroutineScope ) + val loadingState = roomList.loadingState + val roomSummaries: Flow> = roomList.summaries .map { roomSummaries -> roomSummaries diff --git a/features/home/impl/src/main/res/values/temporary.xml b/features/home/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..c44001e5a6 --- /dev/null +++ b/features/home/impl/src/main/res/values/temporary.xml @@ -0,0 +1,17 @@ + + + + + Start searching… + Search for chats and messages + Chats + From your messages + Messages + No results + There are no results for “%1$s”. Try a new search term. + diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 8eee8e1ee2..c0cb5f7c01 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -11,6 +11,8 @@ package io.element.android.features.home.impl import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -36,14 +38,14 @@ class DefaultHomeEntryPointTest : RobolectricTest() { acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, directLogoutView = { _ -> lambdaError() }, reportRoomEntryPoint = { _, _, _ -> lambdaError() }, - declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, + declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, ) } val callback = object : HomeEntryPoint.Callback { - override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError() + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) = lambdaError() override fun navigateToCreateRoom() = lambdaError() override fun navigateToCreateSpace() = lambdaError() override fun navigateToSettings() = lambdaError() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 64dfc46ee2..d2e33c77c1 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -20,6 +20,7 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aGlobalSearchState import io.element.android.features.home.impl.search.aRoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState @@ -680,6 +681,7 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, + globalSearchPresenter = { aGlobalSearchState() }, filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 773f428917..bd18cd903e 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -277,7 +277,7 @@ private fun AndroidComposeUiTest.setRoomListView( setSafeContent { HomeView( homeState = aHomeState(roomListState = state), - onRoomClick = onRoomClick, + onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt new file mode 100644 index 0000000000..ef8d373630 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.search.FakeMessageSearch +import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.Optional + +class GlobalSearchPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.isEnabled).isFalse() + assertThat(state.isSearchActive).isFalse() + assertThat(state.queryState.text.toString()).isEmpty() + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) + assertThat(state.results).isEqualTo(AsyncData.Uninitialized) + } + } + } + + @Test + fun `present - isEnabled reflects the feature flag`() = runTest { + val presenter = createGlobalSearchPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MessageSearch.key to true), + ), + ) + presenter.test { + // isEnabled is emitted asynchronously via produceState, so it starts as false and flips to true + var state = awaitItem() + while (!state.isEnabled) { + state = awaitItem() + } + assertThat(state.isEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggle search visibility`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isTrue() + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + } + } + } + + @Test + fun `present - update target`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) + state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + } + awaitItem().let { state -> + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.MESSAGES) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - clear query`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + val initialState = awaitItem() + initialState.queryState.edit { append("A query") } + awaitItem().let { state -> + assertThat(state.queryState.text.toString()).isEqualTo("A query") + state.eventSink(GlobalSearchEvent.ClearQuery) + } + awaitItem().let { state -> + assertThat(state.queryState.text.toString()).isEmpty() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - room search results are emitted when the room list changes`() = runTest { + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(createRoomListLambda = { roomList }) + val presenter = createGlobalSearchPresenter(roomListService = roomListService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized) + + initialState.queryState.edit { append("A query") } + // Let the query be propagated to the data source before emitting results + testScheduler.advanceUntilIdle() + roomList.summaries.emit(listOf(aRoomSummary())) + + // Wait for the presenter to emit a loading state before the success state + consumeItemsUntilPredicate { it.results is AsyncData.Loading } + + val successState = awaitItem() + val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results + assertThat(results).hasSize(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - room search displays the uninitialized state when removing the query`() = runTest { + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(createRoomListLambda = { roomList }) + val presenter = createGlobalSearchPresenter(roomListService = roomListService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized) + + initialState.queryState.edit { append("A query") } + // Let the query be propagated to the data source before emitting results + testScheduler.advanceUntilIdle() + roomList.summaries.emit(listOf(aRoomSummary())) + + // Wait for the presenter to emit a loading state before the success state + consumeItemsUntilPredicate { it.results is AsyncData.Loading } + + val successState = awaitItem() + val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results + assertThat(results).hasSize(1) + + // Remove the query + successState.queryState.edit { replace(0, length, "") } + // Skip the intermediate state where the query is empty but results are still present + skipItems(1) + // It's back to the uninitialized state + assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java) + // This happens even if for some reason the room results still contained any items + assertThat(roomList.summaries.value).isNotEmpty() + } + } + + @Test + fun `present - message search results are mapped when the message search emits`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + testScheduler.advanceUntilIdle() + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results + assertThat(results).hasSize(1) + assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - message search displays the uninitialized state after removing the query`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + testScheduler.advanceUntilIdle() + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results + assertThat(results).hasSize(1) + assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + + // Remove the query + successState.queryState.edit { replace(0, length, "") } + // Skip the intermediate state where the query is empty but results are still present + skipItems(1) + // It's back to the uninitialized state + assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java) + // This happens even if for some reason the message results still contained any items + assertThat(messageSearch.results.value).isNotEmpty() + } + } + + @Test + fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + + successState.eventSink(GlobalSearchEvent.UpdateVisibleRange(IntRange(0, 0))) + runCurrent() + testScheduler.advanceUntilIdle() + + assertThat(messageSearch.paginateCallCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } +} + +private fun TestScope.createGlobalSearchPresenter( + roomListService: RoomListService = FakeRoomListService(), + messageSearchService: MessageSearchService = FakeMessageSearchService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + latestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(), + dateFormatter: DateFormatter = FakeDateFormatter(), + fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), + permalinkParser: PermalinkParser = FakePermalinkParser(), + matrixClient: MatrixClient = FakeMatrixClient(), +): GlobalSearchPresenter { + return GlobalSearchPresenter( + roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { + override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource { + return RoomListSearchDataSource( + coroutineScope = coroutineScope, + roomListService = roomListService, + coroutineDispatchers = testCoroutineDispatchers(), + roomSummaryFactory = aRoomListRoomSummaryFactory(), + ) + } + }, + messageSearchService = messageSearchService, + featureFlagService = featureFlagService, + latestEventFormatter = latestEventFormatter, + dateFormatter = dateFormatter, + fileSizeFormatter = fileSizeFormatter, + permalinkParser = permalinkParser, + coroutineDispatchers = testCoroutineDispatchers(), + matrixClient = matrixClient, + ) +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt index 4174d99222..3b9d512d80 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt @@ -10,13 +10,19 @@ package io.element.android.features.invite.api.declineandblock import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import io.element.android.features.invite.api.InviteData import io.element.android.libraries.architecture.FeatureEntryPoint -fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { +interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { fun createNode( parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: Callback, ): Node + + interface Callback : Plugin { + fun onDeclineSuccess() + } } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt index 2fe5c23088..89a2aaf238 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt @@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope @@ -31,6 +33,7 @@ class DeclineAndBlockNode( data class Inputs(val inviteData: InviteData) : NodeInputs private val inviteData = inputs().inviteData + private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback() private val presenter = presenterFactory.create(inviteData) @Composable @@ -39,6 +42,7 @@ class DeclineAndBlockNode( DeclineAndBlockView( state = state, onBackClick = ::navigateUp, + onDeclineSuccess = callback::onDeclineSuccess, modifier = modifier ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt index fe82be5b1a..c30742de12 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun DeclineAndBlockView( state: DeclineAndBlockState, onBackClick: () -> Unit, + onDeclineSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -53,7 +54,7 @@ fun DeclineAndBlockView( val isDeclining = state.declineAction is AsyncAction.Loading AsyncActionView( async = state.declineAction, - onSuccess = { onBackClick() }, + onSuccess = { onDeclineSuccess() }, errorMessage = { stringResource(CommonStrings.error_unknown) }, onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) }, onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) } @@ -149,5 +150,6 @@ internal fun DeclineAndBlockViewPreview( DeclineAndBlockView( state = state, onBackClick = {}, + onDeclineSuccess = {}, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt index d6e93fb805..4a5734af45 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt @@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: DeclineInviteAndBlockEntryPoint.Callback, ): Node { val inputs = DeclineAndBlockNode.Inputs(inviteData) - return parentNode.createNode(buildContext, plugins = listOf(inputs)) + return parentNode.createNode(buildContext, plugins = listOf(inputs, callback)) } } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt index 55c31a09d2..5cfb091e82 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.invite.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder @@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() { } } + @Test + fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setDeclineAndBlockView( + aDeclineAndBlockState( + declineAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onDeclineSuccess = it, + ) + } + } + @Test fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() { private fun AndroidComposeUiTest.setDeclineAndBlockView( state: DeclineAndBlockState, onBackClick: () -> Unit = EnsureNeverCalled(), + onDeclineSuccess: () -> Unit = EnsureNeverCalled(), ) { setContent { DeclineAndBlockView( state = state, onBackClick = onBackClick, + onDeclineSuccess = onDeclineSuccess, ) } } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt index 13bb13dccf..f09cda2337 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt @@ -11,7 +11,9 @@ package io.element.android.features.invite.impl.declineandblock import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.invite.test.anInviteData +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test @@ -31,12 +33,17 @@ class DefaultDeclineAndBlockEntryPointTest { ) } val inviteData = anInviteData() + val callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() = lambdaError() + } val result = entryPoint.createNode( parentNode = parentNode, buildContext = BuildContext.root(null), - inviteData = inviteData + inviteData = inviteData, + callback = callback, ) assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java) assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData)) + assertThat(result.plugins).contains(callback) } } diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt index 52d4d17790..19625f1473 100644 --- a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt @@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: DeclineInviteAndBlockEntryPoint.Callback, ): Node { lambdaError() } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt index 93f8f53412..493c180731 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt @@ -69,6 +69,9 @@ class JoinRoomFlowNode( parentNode = this, buildContext = buildContext, inviteData = navTarget.inviteData, + callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() = navigateUp() + }, ) NavTarget.Root -> rootNode(buildContext) } @@ -99,7 +102,7 @@ class JoinRoomFlowNode( acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, onAcceptInviteSuccess = {}, - onDeclineInviteSuccess = {}, + onDeclineInviteSuccess = { navigateUp() }, modifier = Modifier ) } diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt index d11dc7e7e8..e2065bca1f 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt @@ -60,7 +60,13 @@ class LeaveRoomPresenter( roomId: RoomId, leaveAction: MutableState>, ) = launch(dispatchers.io) { - client.getRoom(roomId)?.use { room -> + val room = client.getRoom(roomId) + if (room == null) { + Timber.e("Unable to find room $roomId to leave it") + leaveAction.value = AsyncAction.Failure(RoomNotFoundException(roomId)) + return@launch + } + room.use { val roomInfo = room.roomInfoFlow.first() leaveAction.value = when { roomInfo.isDm -> Confirmation.Dm(roomId) @@ -78,7 +84,8 @@ class LeaveRoomPresenter( leaveAction: MutableState>, ) = launch(dispatchers.io) { leaveAction.runCatchingUpdatingState { - client.getRoom(roomId)!!.use { room -> + val room = client.getRoom(roomId) ?: throw RoomNotFoundException(roomId) + room.use { room .leave() .onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) } @@ -100,3 +107,5 @@ class LeaveRoomPresenter( } } } + +internal class RoomNotFoundException(roomId: RoomId) : IllegalStateException("Room $roomId not found") diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt index 90b7f2369f..f8f7a771e1 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt @@ -175,6 +175,32 @@ class LeaveBaseRoomPresenterTest { } } + @Test + fun `present - show error if the room to confirm leaving is not found`() = runTest { + val presenter = createLeaveRoomPresenter(client = FakeMatrixClient()) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + val errorState = awaitItem() + assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show error if the room to leave is not found`() = runTest { + val presenter = createLeaveRoomPresenter(client = FakeMatrixClient()) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false)) + val progressState = awaitItem() + assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading) + val errorState = awaitItem() + assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - reset state after error`() = runTest { val presenter = createLeaveRoomPresenter( diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt index 7afb9a7623..bff81a3e9e 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -39,6 +39,10 @@ class LinkNewDesktopHandler( LinkDesktopStep.Uninitialized ) + /** + * A [StateFlow] that emits the current step of the link desktop process. + * It starts with [LinkDesktopStep.Uninitialized] and will emit other steps as they occur. + */ val stepFlow: StateFlow get() = linkDesktopStepFlow.asStateFlow() @@ -53,15 +57,31 @@ class LinkNewDesktopHandler( timerJob = null } - fun createNewHandler() { - resetJobs() - handler = matrixClient.createLinkDesktopHandler().getOrNull() + /** + * Starts (or restarts) a timer that will emit an error in [stepFlow] after 2 minutes. + */ + fun startTimer() { + timerJob?.cancel() timerJob = sessionScope.launch { delay(2.minutes) linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long."))) } } + /** + * Creates a new [LinkDesktopHandler] and cancels any existing job. + * This should be called when the user wants to retry scanning a QR code. + */ + fun createNewHandler() { + currentJob?.cancel() + currentJob = null + handler = matrixClient.createLinkDesktopHandler().getOrNull() + } + + /** + * Resets the state of the handler and cancels any existing job. + * This should be called when the user wants to start over. + */ fun reset() { resetJobs() sessionScope.launch { @@ -69,6 +89,12 @@ class LinkNewDesktopHandler( } } + /** + * Handles the scanned QR code data. + * This should be called when the user scans a QR code. + * + * @param data The scanned QR code data as a byte array. + */ fun onScannedCode(data: ByteArray) { resetJobs() val currentHandler = handler diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index afa570f0a9..b31aca8cb2 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -263,6 +263,8 @@ class LinkNewDeviceFlowNode( } LinkDeviceType.Desktop -> { linkNewDesktopHandler.reset() + // Ensure unlock state does not last longer than the timeout for scanning the QR code, so start the timer after unlock + linkNewDesktopHandler.startTimer() backstack.push(NavTarget.DesktopNotice) } } @@ -278,10 +280,14 @@ class LinkNewDeviceFlowNode( NavTarget.DesktopNotice -> { val callback = object : DesktopNoticeNode.Callback { override fun navigateBack() { + // Stop the timer when the user leaves the screen (user will have to authenticate again) + linkNewDesktopHandler.reset() backstack.pop() } override fun navigateToQrCodeScanner() { + // Start again the timer when the user enters the next screen (they clicked on "Ready to scan") + linkNewDesktopHandler.startTimer() backstack.push(NavTarget.DesktopScanQrCode) } } @@ -290,6 +296,8 @@ class LinkNewDeviceFlowNode( NavTarget.DesktopScanQrCode -> { val callback = object : ScanQrCodeNode.Callback { override fun cancel() { + // start again the timer when the user goes back to the DesktopNoticeNode + linkNewDesktopHandler.startTimer() backstack.pop() } } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt index 39c9c6fef3..9436c0e901 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt @@ -40,9 +40,19 @@ class LinkNewMobileHandler( LinkMobileStep.Uninitialized ) + /** + * A [StateFlow] that emits the current step of the link mobile process. + * It starts with [LinkMobileStep.Uninitialized] and will emit other steps as they occur. + */ val stepFlow: StateFlow get() = linkMobileStepFlow.asStateFlow() + /** + * Creates a new [LinkMobileHandler] and starts the linking process. + * If [forRotating] is true, it indicates that this is a rotation of the QR code. + * + * @param forRotating Whether this is a rotation of the QR code. Defaults to false. + */ fun createAndStartNewHandler(forRotating: Boolean = false) { Timber.tag(loggerTag.value).d("createAndStartNewHandler()") currentJob?.cancel() @@ -62,6 +72,10 @@ class LinkNewMobileHandler( } } + /** + * Resets the handler and cancels any ongoing job. + * This will also emit [LinkMobileStep.Uninitialized] to the [stepFlow]. + */ fun reset() { currentJob?.cancel() currentJob = null @@ -70,10 +84,18 @@ class LinkNewMobileHandler( } } + /** + * Rotates the QR code by creating a new handler and starting the linking process. + * This is typically called when a timeout occurs for the current QR code. + */ fun rotateQrCode() { createAndStartNewHandler(forRotating = true) } + /** + * Handles the case where there are too many QR code rotations. + * This will reset the handler and emit an error to the [stepFlow]. + */ fun onTooManyRotation() { reset() sessionScope.launch { diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index b3eeacf87f..8830b39a22 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(projects.libraries.wellknown.api) + implementation(projects.features.enterprise.api) implementation(libs.coil.compose) implementation(libs.datetime) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index a2212119f5..752dd2401c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -8,7 +8,7 @@ package io.element.android.features.location.api import androidx.compose.runtime.staticCompositionLocalOf -import io.element.android.libraries.wellknown.api.MapTilerConfig +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** * Provides a [MapTilerConfig] for the MapLibre maps. diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 33e454722f..b3149964e4 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -37,6 +37,7 @@ import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.SuccessResult import io.element.android.compound.theme.ElementTheme +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.rememberStaticMapBuilder @@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.wellknown.api.MapTilerConfig /** * Shows a static map image downloaded via a third party service's static maps API. diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index 78eae2f633..cd339f67b0 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -8,8 +8,8 @@ package io.element.android.features.location.api.internal +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.BuildConfig -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlin.math.roundToInt /** diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index 10aef90116..d817a3307f 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -10,8 +10,8 @@ package io.element.android.features.location.api.internal +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.BuildConfig -import io.element.android.libraries.wellknown.api.MapTilerConfig internal class MapTilerTileServerStyleUriBuilder( private val baseUrl: String, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 212da08647..31992b1dd6 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -10,8 +10,7 @@ package io.element.android.features.location.api.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import io.element.android.libraries.wellknown.api.MapTilerConfig - +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** * Builds an URL for a 3rd party service provider static maps API. */ diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 9676005f59..67936525e5 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -11,7 +11,7 @@ package io.element.android.features.location.api.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.wellknown.api.MapTilerConfig +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** * Builds a style URI for a MapLibre compatible tile server. diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index 09aae4f310..c153ab1ce4 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -9,7 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.wellknown.api.MapTilerConfig +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index eaa238bed0..84c4ab4fba 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults import io.element.android.libraries.architecture.AsyncData @@ -50,7 +51,6 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.wellknown.api.MapTilerConfig import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapOptions diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index d388e0c5d2..55f1e088d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -22,7 +22,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -73,7 +73,7 @@ class ShareLocationPresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationStore: LiveLocationStore, private val userLocationStateFactory: UserLocationState.Factory, - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, + private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, ) : Presenter { @AssistedFactory fun interface Factory { @@ -96,7 +96,7 @@ class ShareLocationPresenter( val currentUser by client.userProfile.collectAsState() val customMapConfig by produceState(AsyncData.Loading()) { // Ignore errors - value = AsyncData.Success(remoteEnterpriseConfigProvider.get(client.sessionId).dataOrNull()?.mapTilerConfig) + value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) } val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> perms.sendLiveLocationPermissions() diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index d2cce94ef8..b29228f866 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -8,12 +8,12 @@ package io.element.android.features.location.impl.share +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt index 0867136651..908c7f0641 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt @@ -9,13 +9,13 @@ package io.element.android.features.location.impl.share import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.persistentListOf import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 2d9a4900dd..bbec7b449f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.live.ActiveLiveLocationShareManager @@ -47,7 +47,6 @@ import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.joinedRoomMembers @@ -58,18 +57,18 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch -@AssistedInject class ShowLocationPresenter( +@AssistedInject +class ShowLocationPresenter( @Assisted private val mode: ShowLocationMode, permissionsPresenterFactory: PermissionsPresenter.Factory, private val locationActions: LocationActions, private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, - private val sessionId: SessionId, private val joinedRoom: JoinedRoom, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val userLocationStateFactory: UserLocationState.Factory, - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, + private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, ) : Presenter { @AssistedFactory fun interface Factory { fun create(mode: ShowLocationMode): ShowLocationPresenter @@ -89,7 +88,7 @@ import kotlinx.coroutines.launch val customMapTilerConfig by produceState(AsyncData.Loading()) { // Ignore errors - value = AsyncData.Success(remoteEnterpriseConfigProvider.get(sessionId).dataOrNull()?.mapTilerConfig) + value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) } fun checkLocationConstraints() { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 2ae76ef9e9..0472eb94e9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -8,6 +8,7 @@ package io.element.android.features.location.impl.show +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData @@ -17,7 +18,6 @@ import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt index 7330a9a5e1..8473eaeb04 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState @@ -18,7 +19,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.toImmutableList class ShowLocationStatePreviewParam : PreviewParameterProvider { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index 9be168e5a2..a37011e1d9 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl.share 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.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter @@ -59,7 +58,7 @@ class DefaultShareLocationEntryPointTest { sessionId = room.sessionId, ), userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider = { Result.success(null) }, ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 6748c5a675..d249fdaf69 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -19,8 +19,8 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory @@ -32,8 +32,6 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.test.FakeMessageComposerContext -import io.element.android.features.wellknown.test.aMapTilerConfig -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -55,7 +53,6 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.assert @@ -92,7 +89,7 @@ class ShareLocationPresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), client: FakeMatrixClient = fakeMatrixClient, - remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider: () -> Result = { Result.success(null) }, ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -106,7 +103,7 @@ class ShareLocationPresenterTest { liveLocationShareManager = liveLocationShareManager, liveLocationStore = liveLocationStore, userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider, + customMapTilerConfigProvider = customMapTilerConfigProvider, ) @Test @@ -134,9 +131,7 @@ class ShareLocationPresenterTest { client = FakeMatrixClient( sessionId = A_USER_ID, ), - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( - getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) } - ) + customMapTilerConfigProvider = { Result.success(mapTilerConfig) } ) shareLocationPresenter.test { val state = awaitItem() diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index 69451eedb7..db5c6b9f9f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl.show 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.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationMode @@ -21,7 +20,6 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.analytics.test.FakeAnalyticsService @@ -51,10 +49,9 @@ class DefaultShowLocationEntryPointTest { dateFormatter = FakeDateFormatter(), stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, - sessionId = A_SESSION_ID, liveLocationShareManager = FakeActiveLiveLocationShareManager(), userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider = { Result.success(null) }, ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 77b26eb66c..7d9b0d5e03 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -12,7 +12,8 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState @@ -23,20 +24,15 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.test.FakeActiveLiveLocationShareManager -import io.element.android.features.wellknown.test.aMapTilerConfig -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.dateformatter.test.FakeDateFormatter -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.LiveLocationShare -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -69,9 +65,8 @@ class ShowLocationPresenterTest { ), locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), - sessionId: SessionId = A_SESSION_ID, liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), - remoteEnterpriseConfigProvider: FakeRemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) }, ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -80,21 +75,15 @@ class ShowLocationPresenterTest { dateFormatter = fakeDateFormatter, stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, - sessionId = sessionId, liveLocationShareManager = liveLocationShareManager, userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider, + customMapTilerConfigProvider = customMapTilerConfigProvider, ) @Test fun `present - non-null customMapStyleUrl`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") - val shareLocationPresenter = createShowLocationPresenter( - sessionId = A_SESSION_ID, - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( - getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) } - ) - ) + val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) }) shareLocationPresenter.test { val state = awaitItem() assertThat(state.customMapTilerConfig.isLoading()).isTrue() @@ -131,12 +120,7 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - val presenter = createShowLocationPresenter( - sessionId = A_SESSION_ID, - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( - getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = aMapTilerConfig())) } - ) - ) + val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) }) presenter.test { val initialState = awaitItem() diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 86a8e0e7bc..6cf8945e05 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) implementation(projects.libraries.oauth.api) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiUtils) implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) @@ -80,6 +81,7 @@ dependencies { testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.preferences.test) + testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index a662f20351..bbfe7c47d8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.replace -import com.bumble.appyx.navmodel.backstack.operation.singleTop import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject @@ -33,13 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode -import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode import io.element.android.features.login.impl.screens.classic.ClassicFlowNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode -import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.BackstackView @@ -129,12 +126,6 @@ class LoginFlowNode( @Parcelize data object ChooseAccountProvider : NavTarget - @Parcelize - data object ChangeAccountProvider : NavTarget - - @Parcelize - data object SearchAccountProvider : NavTarget - @Parcelize data class LoginPassword( val initialLogin: String = "", @@ -261,43 +252,9 @@ class LoginFlowNode( override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword()) } - - override fun navigateToChangeAccountProvider() { - backstack.push(NavTarget.ChangeAccountProvider) - } } createNode(buildContext, plugins = listOf(inputs, callback)) } - NavTarget.ChangeAccountProvider -> { - val callback = object : ChangeAccountProviderNode.Callback { - override fun onDone() { - // Go back to the Account Provider screen - val confirmAccountProvider = backstack.elements.value.firstOrNull { - it.key.navTarget is NavTarget.ConfirmAccountProvider - }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) - backstack.singleTop(confirmAccountProvider) - } - - override fun navigateToSearchAccountProvider() { - backstack.push(NavTarget.SearchAccountProvider) - } - } - - createNode(buildContext, plugins = listOf(callback)) - } - NavTarget.SearchAccountProvider -> { - val callback = object : SearchAccountProviderNode.Callback { - override fun onDone() { - // Go back to the Account Provider screen - val confirmAccountProvider = backstack.elements.value.firstOrNull { - it.key.navTarget is NavTarget.ConfirmAccountProvider - }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) - backstack.singleTop(confirmAccountProvider) - } - } - - createNode(buildContext, plugins = listOf(callback)) - } is NavTarget.LoginPassword -> { val inputs = LoginPasswordNode.Inputs( initialLogin = navTarget.initialLogin, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt index 119923c67d..a3a475df2f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt @@ -11,17 +11,14 @@ package io.element.android.features.login.impl.accesscontrol import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.impl.changeserver.AccountProviderAccessException -import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever @ContributesBinding(AppScope::class) class DefaultAccountProviderAccessControl( + private val isEnterpriseBuild: IsEnterpriseBuild, private val enterpriseService: EnterpriseService, - private val wellknownRetrieverFactory: WellknownRetriever.Factory, - private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory, ) : AccountProviderAccessControl { override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { assertIsAllowedToConnectToAccountProvider( @@ -38,29 +35,19 @@ class DefaultAccountProviderAccessControl( title: String, accountProviderUrl: String, ) { - if (enterpriseService.isEnterpriseBuild.not()) { + if (isEnterpriseBuild().not()) { // Ensure that Element Pro is not required for this account provider - val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow() - temporaryMatrixClient.use { - val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient) - val wellKnown = wellknownRetriever.getElementWellKnown( - host = accountProviderUrl, - // It's expected this is hardcoded to the well-known endpoint - source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT, - ).dataOrNull() - - if (wellKnown?.enforceElementPro == true) { - throw AccountProviderAccessException.NeedElementProException( - unauthorisedAccountProviderTitle = title, - applicationId = ELEMENT_PRO_APPLICATION_ID, - ) - } + if (enterpriseService.isElementProEnforced(accountProviderUrl)) { + throw AccountProviderAccessException.NeedElementProException( + unauthorisedAccountProviderTitle = title, + applicationId = ELEMENT_PRO_APPLICATION_ID, + ) } } if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { throw AccountProviderAccessException.UnauthorizedAccountProviderException( unauthorisedAccountProviderTitle = title, - authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), + authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(), ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt index 931959d4ea..6d85f2c5ff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -13,27 +13,64 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.canConnectToAnyHomeserver +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch @SingleIn(AppScope::class) @Inject class AccountProviderDataSource( - enterpriseService: EnterpriseService, + private val enterpriseService: EnterpriseService, + private val appPreferencesStore: AppPreferencesStore, + @AppCoroutineScope private val coroutineScope: CoroutineScope, ) { - private val defaultAccountProvider = createAccountProvider( - url = enterpriseService.defaultHomeserverList() + // The provider used when the user has not selected one: an enterprise/MDM-configured provider, + // else matrix.org. The most recently used provider (from history) can override it, see init. + private val configuredAccountProvider = createAccountProvider( + url = enterpriseService.homeserverAllowList() .firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: AuthenticationConfig.MATRIX_ORG_URL ) - private val accountProvider: MutableStateFlow = MutableStateFlow(defaultAccountProvider) + private val accountProvider: MutableStateFlow = MutableStateFlow(configuredAccountProvider) val flow: StateFlow = accountProvider.asStateFlow() + // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]). + // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a + // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip). + var lastSelectedAccountProviderUrl: String? = null + private set + + init { + // Seed the default from the last used provider, unless the user has already selected one. + coroutineScope.launch { + val default = defaultAccountProvider() + accountProvider.update { current -> if (current == configuredAccountProvider) default else current } + } + } + suspend fun reset() { - accountProvider.emit(defaultAccountProvider) + accountProvider.emit(defaultAccountProvider()) + } + + /** + * The provider to default to: the most recently used one from history when the user is free to + * connect to any provider, otherwise the enterprise/MDM-configured provider. + */ + private suspend fun defaultAccountProvider(): AccountProvider { + if (!enterpriseService.canConnectToAnyHomeserver()) { + return configuredAccountProvider + } + val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull() + return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider } suspend fun setUrl(url: String) { @@ -41,6 +78,7 @@ class AccountProviderDataSource( } suspend fun setAccountProvider(data: AccountProvider) { + lastSelectedAccountProviderUrl = data.url accountProvider.emit(data) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt deleted file mode 100644 index 8b9b22022e..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.accountprovider - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.HorizontalDivider -import io.element.android.libraries.designsystem.theme.components.Text - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 - */ -@Composable -fun AccountProviderOtherView( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .clickable(role = Role.Button) { onClick() } - ) { - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(vertical = 4.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RoundedIconAtom( - size = RoundedIconAtomSize.Medium, - imageVector = CompoundIcons.Search(), - tint = ElementTheme.colors.iconPrimary, - ) - Text( - modifier = Modifier - .padding(start = 16.dp) - .weight(1f), - text = stringResource(R.string.screen_change_account_provider_other), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun AccountProviderOtherViewPreview() = ElementPreview { - AccountProviderOtherView( - onClick = { }, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt new file mode 100644 index 0000000000..b3b99115cc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.preferences.api.store.AppPreferencesStore + +/** + * Persist the account provider the user just authenticated against, so that it can be offered as + * the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next + * sign-in. To be called from the manual sign-in / account creation flows only, on success. + * + * See [AppPreferencesStore.addHomeserverToHistory]. + */ +@Inject +class SaveAccountProviderToHistory( + private val accountProviderDataSource: AccountProviderDataSource, + private val appPreferencesStore: AppPreferencesStore, +) { + suspend operator fun invoke() { + // Persist the provider the user actually selected, not the currently-exposed default: the latter can be + // reset back to the history default while the login flow is torn down across an OAuth round-trip. + val url = accountProviderDataSource.lastSelectedAccountProviderUrl + ?: accountProviderDataSource.flow.value.url + appPreferencesStore.addHomeserverToHistory(url) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index 79f76d5fb5..b7b04beeb2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails @Inject class ChangeServerPresenter( @@ -32,7 +33,7 @@ class ChangeServerPresenter( ) : Presenter { @Composable override fun present(): ChangeServerState { - val changeServerAction: MutableState> = remember { + val changeServerAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } @@ -59,7 +60,7 @@ class ChangeServerPresenter( private suspend fun changeServer( data: AccountProvider, - changeServerAction: MutableState>, + changeServerAction: MutableState>, ) { suspend { defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( @@ -72,6 +73,8 @@ class ChangeServerPresenter( } // Homeserver is valid, remember user choice accountProviderDataSource.setAccountProvider(data) + // Return the resolved details so the caller can sign in without configuring the homeserver again. + details }.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt index 6d8ce25d75..3b4f4d9dad 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -9,10 +9,13 @@ package io.element.android.features.login.impl.changeserver import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog data class ChangeServerState( - val changeServerAction: AsyncData, + // On success, carries the resolved homeserver details so the caller can proceed with login without + // configuring (and re-networking) the homeserver a second time. + val changeServerAction: AsyncData, val localNetworkPermissionDialog: LocalNetworkPermissionDialog, val eventSink: (ChangeServerEvents) -> Unit ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt index 5f46d877a2..2654953ece 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt @@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog open class ChangeServerStatePreviewParam : PreviewParameterProvider { @@ -45,7 +46,7 @@ open class ChangeServerStatePreviewParam : PreviewParameterProvider = AsyncData.Uninitialized, + changeServerAction: AsyncData = AsyncData.Uninitialized, localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None, ) = ChangeServerState( changeServerAction = changeServerAction, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt index 73400890d5..945b0bc580 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt @@ -7,12 +7,18 @@ package io.element.android.features.login.impl.login +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails + sealed interface LoginModeEvent { data class Submit( val isAccountCreation: Boolean, val homeserverUrl: String, val resolvedHomeserverUrl: String?, val loginHint: String?, + // When the caller has already configured the homeserver (e.g. the account provider screen validated + // it first), these are its resolved details. When set, login reuses them instead of calling + // setHomeserver again, avoiding a redundant network round-trip. + val preConfiguredDetails: MatrixHomeServerDetails? = null, ) : LoginModeEvent data object ClearError : LoginModeEvent diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt index 466cd210ab..59d156308f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter @@ -39,6 +40,7 @@ class LoginModePresenter( private val oAuthActionFlow: OAuthActionFlow, private val authenticationService: MatrixAuthenticationService, private val localNetworkPermissionGate: LocalNetworkPermissionGate, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @Composable override fun present(): LoginModeState { @@ -73,26 +75,28 @@ class LoginModePresenter( private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState>) { suspend { - authenticationService.setHomeserver(request.homeserverUrl).recoverCatching { - // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one. - if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) { - authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow() - } else { - throw it - } - }.map { matrixHomeServerDetails -> - when { - matrixHomeServerDetails.supportsOAuthLogin -> { - val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login - LoginMode.OAuth( - authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow() - ) + // Reuse the details the caller already resolved when it configured the homeserver, so we don't + // run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves. + val matrixHomeServerDetails = request.preConfiguredDetails + ?: authenticationService.setHomeserver(request.homeserverUrl).recoverCatching { + // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one. + if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) { + authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow() + } else { + throw it } - request.isAccountCreation -> throw AccountCreationNotSupported() - matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin - else -> error("Unsupported authentication flow") + }.getOrThrow() + when { + matrixHomeServerDetails.supportsOAuthLogin -> { + val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login + LoginMode.OAuth( + authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow() + ) } - }.getOrThrow() + request.isAccountCreation -> throw AccountCreationNotSupported() + matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin + else -> error("Unsupported authentication flow") + } }.runCatchingUpdatingState( state = loginMode, errorTransform = { @@ -116,6 +120,7 @@ class LoginModePresenter( .onSuccess { loginMode.value = AsyncData.Uninitialized } .onFailure { loginMode.value = AsyncData.Failure(it) } is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url) + .onSuccess { saveAccountProviderToHistory() } .onFailure { loginMode.value = AsyncData.Failure(it) } } oAuthActionFlow.reset() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt deleted file mode 100644 index d9994deb9a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.resolver - -data class HomeserverData( - // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url - val homeserverUrl: String, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt deleted file mode 100644 index 2700922678..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.resolver - -import dev.zacsweers.metro.Inject -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.core.coroutine.parallelMap -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.core.uri.isValidUrl -import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker -import io.element.android.libraries.matrix.api.core.MatrixPatterns -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.util.Collections - -/** - * Resolve homeserver base on search terms. - */ -@Inject -class HomeserverResolver( - private val dispatchers: CoroutineDispatchers, - private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, - private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, -) { - fun resolve(userInput: String): Flow> = flow { - val flowContext = currentCoroutineContext() - val trimmedUserInput = userInput.trim() - val searchTerm = when { - MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty() - trimmedUserInput.startsWith("@") -> return@flow - else -> trimmedUserInput - } - if (searchTerm.length < 4) return@flow - val candidateBase = searchTerm.ensureProtocol().removeSuffix("/") - val list = getUrlCandidates(candidateBase) - val currentList = Collections.synchronizedList(mutableListOf()) - // Run all the requests in parallel - withContext(dispatchers.io) { - list.parallelMap { url -> - // Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first — - // otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a - // candidate directly; the actual sign-in flow triggers the permission prompt. - val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) || - homeServerLoginCompatibilityChecker.check(url) - .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") } - .getOrNull() - ?: return@parallelMap - - // Emit the list as soon as possible - if (shouldRequestPermissionOrIsValid) { - currentList.add(HomeserverData(homeserverUrl = url)) - withContext(flowContext) { - emit(currentList.toList()) - } - } - } - } - // If list is empty, and candidateBase is a valid an URL, do not block the user. - // A unsupported homeserver / homeserver not found error will be displayed if the user continues - if (currentList.isEmpty() && candidateBase.isValidUrl()) { - emit(listOf(HomeserverData(homeserverUrl = candidateBase))) - } - } - - private fun getUrlCandidates(data: String): List { - return buildList { - if (data.contains(".")) { - // TLD detected? - } else { - add("$data.org") - add("$data.com") - add("$data.io") - } - // Always try what the user has entered - add(data) - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt deleted file mode 100644 index 018a25dc89..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.login.impl.util.openLearnMorePage -import io.element.android.libraries.architecture.callback - -@ContributesNode(AppScope::class) -@AssistedInject -class ChangeAccountProviderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - private val presenter: ChangeAccountProviderPresenter, -) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { - fun onDone() - fun navigateToSearchAccountProvider() - } - - private val callback: Callback = callback() - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - val context = LocalContext.current - ChangeAccountProviderView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onLearnMoreClick = { openLearnMorePage(context) }, - onSuccess = callback::onDone, - onOtherProviderClick = callback::navigateToSearchAccountProvider, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt deleted file mode 100644 index 41605352ef..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import dev.zacsweers.metro.Inject -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.api.canConnectToAnyHomeserver -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.uri.ensureProtocol -import kotlinx.collections.immutable.toImmutableList - -@Inject -class ChangeAccountProviderPresenter( - private val changeServerPresenter: Presenter, - private val enterpriseService: EnterpriseService, -) : Presenter { - @Composable - override fun present(): ChangeAccountProviderState { - val staticAccountProviderList = remember { - enterpriseService.defaultHomeserverList() - .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } - .map { it.ensureProtocol() } - .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) } - .map { url -> - AccountProvider( - url = url, - subtitle = null, - isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, - isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - ) - } - .toImmutableList() - } - - val canSearchForAccountProviders = remember { - enterpriseService.canConnectToAnyHomeserver() - } - - val changeServerState = changeServerPresenter.present() - return ChangeAccountProviderState( - accountProviders = staticAccountProviderList, - canSearchForAccountProviders = canSearchForAccountProviders, - changeServerState = changeServerState, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt deleted file mode 100644 index 3068a1cb57..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import kotlinx.collections.immutable.ImmutableList - -data class ChangeAccountProviderState( - val accountProviders: ImmutableList, - val canSearchForAccountProviders: Boolean, - val changeServerState: ChangeServerState, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStatePreviewParam.kt deleted file mode 100644 index 389531fffb..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStatePreviewParam.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.anAccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.changeserver.aChangeServerState -import kotlinx.collections.immutable.toImmutableList - -open class ChangeAccountProviderStatePreviewParam : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aChangeAccountProviderState(), - aChangeAccountProviderState(canSearchForAccountProviders = false), - // Add other state here - ) -} - -fun aChangeAccountProviderState( - accountProviders: List = listOf( - anAccountProvider() - ), - canSearchForAccountProviders: Boolean = true, - changeServerState: ChangeServerState = aChangeServerState(), -) = ChangeAccountProviderState( - accountProviders = accountProviders.toImmutableList(), - canSearchForAccountProviders = canSearchForAccountProviders, - changeServerState = changeServerState, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt deleted file mode 100644 index 54bd0ea763..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ /dev/null @@ -1,131 +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( - ChangeAccountProviderStatePreviewParam::class -) state: ChangeAccountProviderState) = ElementPreview { - ChangeAccountProviderView( - state = state, - onBackClick = { }, - onLearnMoreClick = { }, - onSuccess = { }, - onOtherProviderClick = { }, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt index a9983a19d1..92c3ff0fff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -60,7 +60,7 @@ class ChooseAccountProviderPresenter( val staticAccountProviderList = remember { // The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point - enterpriseService.defaultHomeserverList() + enterpriseService.homeserverAllowList() .map { it.ensureProtocol() } .map { url -> AccountProvider( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt index d66606b22c..9093a8121f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -9,6 +9,10 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider sealed interface ConfirmAccountProviderEvents { - data object Continue : ConfirmAccountProviderEvents + data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents + + // Carries the account provider to submit (the current field text, or the accepted autocomplete suggestion), + // captured from the view at click time so it can't lag behind fast input (e.g. keyboard autofill). + data class Continue(val accountProvider: String) : ConfirmAccountProviderEvents data object ClearError : ConfirmAccountProviderEvents } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index 0accaea767..e1a2d85412 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -45,7 +45,6 @@ class ConfirmAccountProviderNode( interface Callback : Plugin { fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToChangeAccountProvider() } private val callback: Callback = callback() @@ -59,7 +58,6 @@ class ConfirmAccountProviderNode( modifier = modifier, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, - onChange = callback::navigateToChangeAccountProvider, onLearnMoreClick = { openLearnMorePage(context) }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 0111b1e7d7..5c66f8f446 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -9,21 +9,38 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.preferences.api.store.AppPreferencesStore @AssistedInject class ConfirmAccountProviderPresenter( @Assisted private val params: Params, private val accountProviderDataSource: AccountProviderDataSource, private val loginModePresenter: Presenter, + private val changeServerPresenter: Presenter, + private val appPreferencesStore: AppPreferencesStore, + private val enterpriseService: EnterpriseService, ) : Presenter { data class Params( val isAccountCreation: Boolean, @@ -38,26 +55,108 @@ class ConfirmAccountProviderPresenter( override fun present(): ConfirmAccountProviderState { val accountProvider by accountProviderDataSource.flow.collectAsState() val loginModeState = loginModePresenter.present() + val changeServerState = changeServerPresenter.present() + val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) + + // The account providers offered for autocomplete: previously-used ones first, then the + // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in). + // The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is + // rendered without the https:// scheme (added back at connection time). + val autocompleteCandidates = remember(homeserverHistory) { + (homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL) + .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } + .map { it.withoutScheme() } + .distinct() + } + + // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. + // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org". + var userInput by rememberSaveable { mutableStateOf(null) } + val accountProviderInput = userInput ?: accountProvider.url.withoutScheme() + + // Offer the first candidate that the current input is a (case-insensitive) prefix of. + val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) { + val input = accountProviderInput.trim() + input.takeIf { it.isNotEmpty() } + ?.let { prefix -> + autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } + } + } + + // The account provider submitted via Continue (carried by the event, so it reflects the exact field + // text at click time); used to actually sign in once validation succeeds. + var submittedAccountProvider by remember { mutableStateOf(null) } + val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider) + + // A login hint (the full Matrix user ID) captured when the user enters one, so the authentication + // server can pre-fill their identity. Null when a plain account provider was entered. + var submittedLoginHint by remember { mutableStateOf(null) } + val latestSubmittedLoginHint by rememberUpdatedState(submittedLoginHint) + + // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. + // Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again. + LaunchedEffect(changeServerState.changeServerAction) { + val homeServerDetails = changeServerState.changeServerAction.dataOrNull() + if (homeServerDetails != null) { + loginModeState.eventSink( + LoginModeEvent.Submit( + isAccountCreation = params.isAccountCreation, + homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(), + resolvedHomeserverUrl = null, + loginHint = latestSubmittedLoginHint, + preConfiguredDetails = homeServerDetails, + ) + ) + } + } fun handleEvent(event: ConfirmAccountProviderEvents) { when (event) { - ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink( - LoginModeEvent.Submit( - isAccountCreation = params.isAccountCreation, - homeserverUrl = accountProvider.url, - resolvedHomeserverUrl = null, - loginHint = null, + is ConfirmAccountProviderEvents.UserInputChanged -> { + userInput = event.accountProvider + } + // Validate (and persist) the chosen account provider before proceeding. This also enforces the + // account-provider access control, which the login submit does not run on its own. + is ConfirmAccountProviderEvents.Continue -> { + // Apply the accepted account provider (any accepted completion) back into the field so it + // renders the full server rather than the typed prefix, and survives the OAuth round-trip. + val accountProvider = event.accountProvider.trim() + userInput = accountProvider + // If the user entered a full Matrix user ID (e.g. "@alice:example.org"), sign in to its + // homeserver and pass the ID as a login hint so the authentication server can pre-fill it. + // Otherwise treat the input as the account provider host. Only the homeserver is ever + // persisted to history, never the user ID. + val isUserId = MatrixPatterns.isUserId(accountProvider) + // For a user ID the homeserver is the domain part (after the ':'), keeping any port. + val host = if (isUserId) accountProvider.substringAfter(":") else accountProvider + submittedLoginHint = "mxid:$accountProvider".takeIf { isUserId && !params.isAccountCreation } + // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works. + val accountProviderUrl = host.ensureProtocol() + submittedAccountProvider = accountProviderUrl + changeServerState.eventSink( + ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl)) ) - ) - ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError) + } + ConfirmAccountProviderEvents.ClearError -> { + loginModeState.eventSink(LoginModeEvent.ClearError) + changeServerState.eventSink(ChangeServerEvents.ClearError) + } } } return ConfirmAccountProviderState( - accountProvider = accountProvider, + accountProviderInput = accountProviderInput, + accountProviderSuggestion = accountProviderSuggestion, isAccountCreation = params.isAccountCreation, loginModeState = loginModeState, + changeServerState = changeServerState, eventSink = ::handleEvent, ) } } + +/** + * The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed. + * Returns the input unchanged when it has no scheme (e.g. `matrix.org`). + */ +private fun String.withoutScheme(): String = substringAfter("://") diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt index 6e6deb8c2c..bf6b0cb0e7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -8,17 +8,29 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider -import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData data class ConfirmAccountProviderState( - val accountProvider: AccountProvider, + val accountProviderInput: String, + // The full account provider from history that the current input is a prefix of, offered as inline autocomplete. + val accountProviderSuggestion: String?, val isAccountCreation: Boolean, val loginModeState: LoginModeState, + val changeServerState: ChangeServerState, val eventSink: (ConfirmAccountProviderEvents) -> Unit ) { val submitEnabled: Boolean - get() = accountProvider.url.isNotEmpty() && - (loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading) + get() = accountProviderInput.isNotBlank() && + loginModeState.loginMode is AsyncData.Uninitialized && + changeServerState.changeServerAction is AsyncData.Uninitialized + + val isLoading: Boolean + get() = loginModeState.loginMode is AsyncData.Loading || + changeServerState.changeServerAction is AsyncData.Loading + + val isShowingError: Boolean + get() = loginModeState.loginMode is AsyncData.Failure || + changeServerState.changeServerAction is AsyncData.Failure } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt index ad2628194b..ab6350bb4d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt @@ -9,8 +9,8 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported @@ -20,6 +20,10 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider get() = sequenceOf( aConfirmAccountProviderState(), + aConfirmAccountProviderState( + accountProviderInput = "element", + accountProviderSuggestion = "element.io", + ), aConfirmAccountProviderState( isAccountCreation = true, ), @@ -31,13 +35,17 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider Unit = {}, ) = ConfirmAccountProviderState( - accountProvider = accountProvider, + accountProviderInput = accountProviderInput, + accountProviderSuggestion = accountProviderSuggestion, isAccountCreation = isAccountCreation, loginModeState = loginModeState, + changeServerState = changeServerState, eventSink = eventSink ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 414a7e30e0..1dab5cd31c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -8,50 +8,110 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeView -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +/** + * Account provider entry screen, shared by sign in and account creation. + * Figma (Compound Android Templates): + * - Sign in: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10251 + * - Create account: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10239 + */ @Composable fun ConfirmAccountProviderView( state: ConfirmAccountProviderState, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, - onChange: () -> Unit, modifier: Modifier = Modifier, ) { - val isLoading by remember(state.loginModeState.loginMode) { - derivedStateOf { - state.loginModeState.loginMode is AsyncData.Loading - } - } val eventSink = state.eventSink + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + var input by textFieldState(stateValue = state.accountProviderInput) + + // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and + // accepted on Continue / Enter). Only valid while the suggestion still starts with the current input. + val suggestionSuffix = state.accountProviderSuggestion + ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } + ?.substring(input.length) + .orEmpty() + + // The Continue button lives in a footer slot that is not recomposed while the user types, so a direct + // capture of the field text (via ::submit) goes stale and can submit an empty/prefilled value. Reading + // the value through rememberUpdatedState keeps it current regardless of which composition scope reads it. + val accountProviderToSubmit by rememberUpdatedState(input + suggestionSuffix) + + fun submit() { + // Dismiss the keyboard and release focus while the account provider is being validated. + focusManager.clearFocus(force = true) + // Submit the exact field text (plus any accepted suggestion). + eventSink(ConfirmAccountProviderEvents.Continue(accountProviderToSubmit)) + } + + // Once a validation / login error has been dismissed, return focus and the keyboard to the field so + // the user can correct the account provider and retry. + var wasShowingError by remember { mutableStateOf(false) } + LaunchedEffect(state.isShowingError) { + if (wasShowingError && !state.isShowingError) { + focusRequester.requestFocus() + keyboardController?.show() + } + wasShowingError = state.isShowingError + } HeaderFooterPage( modifier = modifier, @@ -61,54 +121,113 @@ fun ConfirmAccountProviderView( iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { - R.string.screen_account_provider_signup_title + R.string.screen_change_server_title_register } else { - R.string.screen_account_provider_signin_title - }, - state.accountProvider.title + R.string.screen_change_server_title_login + } ), - subTitle = stringResource( - id = if (state.isAccountCreation) { - R.string.screen_account_provider_signup_subtitle - } else { - R.string.screen_account_provider_signin_subtitle - }, - ) + subTitle = null, ) }, footer = { ButtonColumnMolecule { Button( text = stringResource(id = CommonStrings.action_continue), - showProgress = isLoading, - onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, - enabled = state.submitEnabled || isLoading, + showProgress = state.isLoading, + onClick = ::submit, + enabled = state.submitEnabled && !state.isLoading, modifier = Modifier .fillMaxWidth() .testTag(TestTags.loginContinue) ) - TextButton( - text = stringResource(id = R.string.screen_account_provider_change), - onClick = onChange, - enabled = !isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.loginChangeServer) - ) } } ) { - LoginModeView( - loginMode = state.loginModeState.loginMode, - onClearError = { - eventSink(ConfirmAccountProviderEvents.ClearError) + val ghostColor = ElementTheme.colors.textSecondary + val ghostTransformation = remember(suggestionSuffix, ghostColor) { + GhostSuffixVisualTransformation(suggestionSuffix, ghostColor) + } + TextField( + value = input, + onValueChange = { + input = it + eventSink(ConfirmAccountProviderEvents.UserInputChanged(it)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp) + .focusRequester(focusRequester) + .testTag(TestTags.changeServerServer), + label = stringResource(id = R.string.screen_change_server_textfield_header), + placeholder = stringResource(id = R.string.screen_change_server_textfield_placeholder), + supportingText = stringResource( + id = if (state.isAccountCreation) { + R.string.screen_change_server_textfield_footer_register + } else { + R.string.screen_change_server_textfield_footer_login + } + ), + visualTransformation = ghostTransformation, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + if (suggestionSuffix.isNotEmpty()) { + // A completion is showing: accept it into the field and drop focus, dismissing the + // keyboard. This shows the completed server (with no stray mid-text cursor, since the + // field is no longer focused) ready to sign in with Continue. When no completion is + // showing, Done submits directly. + val accepted = input + suggestionSuffix + input = accepted + eventSink(ConfirmAccountProviderEvents.UserInputChanged(accepted)) + focusManager.clearFocus(force = true) + } else { + submit() + } + } + ), + singleLine = true, + trailingIcon = if (input.isNotEmpty()) { + { + Box( + Modifier.clickable( + onClickLabel = stringResource(CommonStrings.action_clear), + role = Role.Button, + ) { + input = "" + eventSink(ConfirmAccountProviderEvents.UserInputChanged("")) + } + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear) + ) + } + } + } else { + null }, - onLearnMoreClick = onLearnMoreClick, - onOAuthDetails = onOAuthDetails, - onNeedLoginPassword = onNeedLoginPassword, ) } + // Renders the account-provider validation errors, progress and permission dialogs. + // The successful validation itself is observed by the presenter, which then proceeds with the login flow. + ChangeServerView( + state = state.changeServerState, + onLearnMoreClick = onLearnMoreClick, + onSuccess = {}, + ) + + LoginModeView( + loginMode = state.loginModeState.loginMode, + onClearError = { eventSink(ConfirmAccountProviderEvents.ClearError) }, + onLearnMoreClick = onLearnMoreClick, + onOAuthDetails = onOAuthDetails, + onNeedLoginPassword = onNeedLoginPassword, + ) + LocalNetworkPermissionDialogView( dialog = state.loginModeState.localNetworkPermissionDialog, onSubmit = { @@ -120,6 +239,29 @@ fun ConfirmAccountProviderView( ) } +/** + * Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint. + * The underlying field value stays limited to what the user actually typed, so the cursor can never + * enter the suggested region. + */ +private class GhostSuffixVisualTransformation( + private val suffix: String, + private val color: Color, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity) + val transformed = buildAnnotatedString { + append(text) + withStyle(SpanStyle(color = color)) { append(suffix) } + } + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length) + } + return TransformedText(transformed, offsetMapping) + } +} + @PreviewsDayNight @Composable internal fun ConfirmAccountProviderViewPreview( @@ -130,6 +272,5 @@ internal fun ConfirmAccountProviderViewPreview( onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, - onChange = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt index f26f342a42..6e28bd8781 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -20,6 +20,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -33,6 +34,7 @@ class LoginPasswordPresenter( private val initialLogin: String, private val authenticationService: MatrixAuthenticationService, private val accountProviderDataSource: AccountProviderDataSource, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @AssistedFactory interface Factory { @@ -83,6 +85,7 @@ class LoginPasswordPresenter( loggedInState.value = AsyncData.Loading() authenticationService.login(formState.login.trim(), formState.password) .onSuccess { sessionId -> + saveAccountProviderToHistory() loggedInState.value = AsyncData.Success(sessionId) } .onFailure { failure -> diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 343c770c84..a168b14cc8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -191,6 +192,7 @@ private fun LoginForm( enabled = !isLoading, modifier = Modifier .fillMaxWidth() + .bringIntoViewOnImeVisible() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginEmailUsername) .semantics { @@ -243,6 +245,7 @@ private fun LoginForm( enabled = !isLoading, modifier = Modifier .fillMaxWidth() + .bringIntoViewOnImeVisible() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginPassword) .semantics { @@ -274,10 +277,10 @@ private fun LoginForm( } /** - * Ensure that the string does not contain any new line characters, which can happen when pasting values. + * Ensure that the string does not contain any line separator, which can happen when pasting values. */ private fun String.sanitize(): String { - return replace("\n", "") + return filterNot { it == '\n' || it == '\r' } } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 29e4b1afe5..e9bdb5e1e9 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -60,15 +60,15 @@ 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.defaultHomeserverList().singleOrNull() + enterpriseService.homeserverAllowList().singleOrNull() } val canConnectToAnyHomeserver = remember { enterpriseService.canConnectToAnyHomeserver() } val mustChooseAccountProvider = remember { - !canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1 + !canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1 } val linkAccountProvider by produceState(initialValue = null) { // Account provider from the link, if allowed by the enterprise service diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt deleted file mode 100644 index 8816de3946..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -sealed interface SearchAccountProviderEvents { - /** - * The user has typed something, expect to get a list of matching account provider results - * in the state. - */ - data class UserInput(val input: String) : SearchAccountProviderEvents -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt deleted file mode 100644 index ddbcc4c847..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.login.impl.util.openLearnMorePage -import io.element.android.libraries.architecture.callback - -@ContributesNode(AppScope::class) -@AssistedInject -class SearchAccountProviderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - private val presenter: SearchAccountProviderPresenter, -) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { - fun onDone() - } - - private val callback: Callback = callback() - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - val context = LocalContext.current - SearchAccountProviderView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onLearnMoreClick = { openLearnMorePage(context) }, - onSuccess = callback::onDone, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt deleted file mode 100644 index 2efe4501c6..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import dev.zacsweers.metro.Inject -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.features.login.impl.resolver.HomeserverResolver -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.architecture.Presenter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Inject -class SearchAccountProviderPresenter( - private val homeserverResolver: HomeserverResolver, - private val changeServerPresenter: Presenter, -) : Presenter { - @Composable - override fun present(): SearchAccountProviderState { - var userInput by rememberSaveable { - mutableStateOf("") - } - val changeServerState = changeServerPresenter.present() - - val data: MutableState>> = remember { - mutableStateOf(AsyncData.Uninitialized) - } - - LaunchedEffect(userInput) { - onUserInput(userInput, data) - } - - fun handleEvent(event: SearchAccountProviderEvents) { - when (event) { - is SearchAccountProviderEvents.UserInput -> { - userInput = event.input - } - } - } - - return SearchAccountProviderState( - userInput = userInput, - userInputResult = data.value, - changeServerState = changeServerState, - eventSink = ::handleEvent, - ) - } - - private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch { - data.value = AsyncData.Uninitialized - // Debounce - delay(500) - data.value = AsyncData.Loading() - homeserverResolver.resolve(userInput).collect { - data.value = AsyncData.Success(it) - } - if (data.value !is AsyncData.Success) { - data.value = AsyncData.Uninitialized - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt deleted file mode 100644 index dc02a997a6..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData - -data class SearchAccountProviderState( - val userInput: String, - val userInputResult: AsyncData>, - val changeServerState: ChangeServerState, - val eventSink: (SearchAccountProviderEvents) -> Unit -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStatePreviewParam.kt deleted file mode 100644 index 373cb1d972..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStatePreviewParam.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData - -open class SearchAccountProviderStatePreviewParam : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aSearchAccountProviderState(), - aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())), - // Add other state here - ) -} - -fun aSearchAccountProviderState( - userInput: String = "", - userInputResult: AsyncData> = AsyncData.Uninitialized, -) = SearchAccountProviderState( - userInput = userInput, - userInputResult = userInputResult, - changeServerState = aChangeServerState(), - eventSink = {} -) - -fun aHomeserverDataList(): List { - return listOf( - aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL), - aHomeserverData(homeserverUrl = "https://no.sliding.sync"), - aHomeserverData(homeserverUrl = "https://invalid"), - ) -} - -fun aHomeserverData( - homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, -): HomeserverData { - return HomeserverData(homeserverUrl = homeserverUrl) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt deleted file mode 100644 index 7530bd8be1..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.AccountProviderView -import io.element.android.features.login.impl.changeserver.ChangeServerEvents -import io.element.android.features.login.impl.changeserver.ChangeServerView -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule -import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.form.textFieldState -import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.TextField -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.ui.strings.CommonStrings - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435 - */ -@Composable -fun SearchAccountProviderView( - state: SearchAccountProviderState, - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onSuccess: () -> Unit, - modifier: Modifier = Modifier, -) { - val eventSink = state.eventSink - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = {}, - navigationIcon = { BackButton(onClick = onBackClick) } - ) - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(padding) - .consumeWindowInsets(padding) - ) { - LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { - item { - IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp), - iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), - title = stringResource(id = R.string.screen_account_provider_form_title), - subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle), - ) - } - item { - // TextInput - var userInputState by textFieldState(stateValue = state.userInput) - val focusManager = LocalFocusManager.current - TextField( - value = userInputState, - // readOnly = isLoading, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .onTabOrEnterKeyFocusNext(focusManager) - .testTag(TestTags.changeServerServer), - onValueChange = { - userInputState = it - eventSink(SearchAccountProviderEvents.UserInput(it)) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { - focusManager.moveFocus(FocusDirection.Down) - }), - singleLine = true, - trailingIcon = if (userInputState.isNotEmpty()) { - { - Box( - Modifier.clickable( - onClickLabel = stringResource(CommonStrings.action_clear), - role = Role.Button, - ) { - userInputState = "" - eventSink(SearchAccountProviderEvents.UserInput("")) - } - ) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_clear) - ) - } - } - } else { - null - }, - supportingText = stringResource(id = R.string.screen_account_provider_form_notice), - ) - } - - when (state.userInputResult) { - is AsyncData.Failure -> { - // Ignore errors (let the user type more chars) - } - is AsyncData.Loading -> { - item { - Box( - modifier = Modifier - .fillMaxSize() - ) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) - } - } - } - is AsyncData.Success -> { - items(state.userInputResult.data) { homeserverData -> - val item = homeserverData.toAccountProvider() - AccountProviderView( - item = item, - onClick = { - state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item)) - } - ) - } - } - AsyncData.Uninitialized -> Unit - } - item { - Spacer(Modifier.height(32.dp)) - } - } - ChangeServerView( - state = state.changeServerState, - onLearnMoreClick = onLearnMoreClick, - onSuccess = onSuccess, - ) - } - } -} - -@Composable -private fun HomeserverData.toAccountProvider(): AccountProvider { - val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL - return AccountProvider( - url = homeserverUrl, - subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, - // There is no need to know for other servers right now - isPublic = isMatrixOrg, - isMatrixOrg = isMatrixOrg, - ) -} - -@PreviewsDayNight -@Composable -internal fun SearchAccountProviderViewPreview(@PreviewParameter( - SearchAccountProviderStatePreviewParam::class -) state: SearchAccountProviderState) = ElementPreview { - SearchAccountProviderView( - state = state, - onBackClick = {}, - onLearnMoreClick = {}, - onSuccess = {}, - ) -} diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index 33b3741234..13a93164a6 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -25,8 +25,16 @@ "No se permite el proveedor de cuentas %1$s." "URL del servidor base" "Introduce una dirección de dominio." + "Iniciar sesión" + "Crear cuenta" "¿Cuál es la dirección de tu servidor?" + "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)." + "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)." + "Proveedor de cuentas" + "ejemplo.com" "Selecciona tu servidor" + "Introduce tu proveedor de cuenta" + "Elegir proveedor de cuentas" "Crear cuenta" "Esta cuenta ha sido eliminada." "Usuario y/o contraseña incorrectos" diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml index dc37ed3e38..a27bc18e3e 100644 --- a/features/login/impl/src/main/res/values-et/translations.xml +++ b/features/login/impl/src/main/res/values-et/translations.xml @@ -25,8 +25,16 @@ "%1$s teenusepakkuja pole lubatud." "Koduserveri url" "Sisesta domeeni aadress." + "Logi sisse" + "Loo kasutajakonto" "Mis on sinu koduserveri aadress?" + "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)." + "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)." + "Teenusepakkuja" + "domeeninäidis.ee" "Vali oma server" + "Sisesta oma kasutajakonto teenusepakkuja" + "Vali kasutajakonto teenusepakkuja" "Loo kasutajakonto" "See kasutajakonto on kustutatud." "Vigane kasutajanimi ja/või salasõna" diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml index 8561bcfce3..493ddee2f8 100644 --- a/features/login/impl/src/main/res/values-fi/translations.xml +++ b/features/login/impl/src/main/res/values-fi/translations.xml @@ -25,8 +25,16 @@ "Palveluntarjoaja %1$s ei ole sallittu." "Kotipalvelimen osoite" "Anna verkkotunnuksen osoite." + "Kirjaudu sisään" + "Luo tili" "Mikä on palvelimesi osoite?" + "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)." + "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)." + "Palveluntarjoaja" + "example.com" "Valitse palvelimesi" + "Anna palveluntarjoajasi" + "Valitse palveluntarjoaja" "Luo tili" "Tämä tili on poistettu." "Väärä käyttäjänimi ja/tai salasana" diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml index 504517aad7..0af658d34f 100644 --- a/features/login/impl/src/main/res/values-fr/translations.xml +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -25,8 +25,16 @@ "Le fournisseur de compte %1$s n’est pas autorisé." "URL du serveur d’accueil" "Saisissez une adresse de domaine." + "Se connecter" + "Créer un compte" "Quelle est l’adresse de votre serveur ?" + "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)." + "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)." + "Fournisseur de compte" + "exemple.com" "Choisissez votre serveur" + "Saisissez votre fournisseur de compte" + "Choisissez un fournisseur de compte" "Créer un compte" "Ce compte a été supprimé." "Nom d’utilisateur et/ou mot de passe incorrects" diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml index 1d29c5b619..34ba1bd0ed 100644 --- a/features/login/impl/src/main/res/values-in/translations.xml +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -25,8 +25,16 @@ "Penyedia akun %1$s tidak diizinkan." "URL Homeserver" "Masukkan alamat domain." + "Login" + "Buat akun" "Apa alamat server Anda?" + "Jika akun Anda disediakan oleh perusahaan atau komunitas Anda, maka ubah penyedia akun (contoh: companyname.com)." + "Buat akun di matrix.org secara gratis. Jika anda ingin membuat akun dengan penyedia lain, ubah sesuai kebutuhan (contoh: communityname.org)." + "Penyedia akun" + "contoh.com" "Pilih server Anda" + "Masukkan penyedia akun Anda" + "Pilih penyedia akun" "Buat akun" "Akun ini telah dinonaktifkan." "Nama pengguna dan/atau kata sandi salah" diff --git a/features/login/impl/src/main/res/values-ja/translations.xml b/features/login/impl/src/main/res/values-ja/translations.xml index 42698e27dc..b4d6f16721 100644 --- a/features/login/impl/src/main/res/values-ja/translations.xml +++ b/features/login/impl/src/main/res/values-ja/translations.xml @@ -24,8 +24,16 @@ "アカウント提供元 %1$s は許可されていません。" "ホームサーバーURL" "ドメイン名を入力してください" + "サインイン" + "アカウントを作成" "サーバーのアドレスは何ですか?" + "アカウントが会社やコミュニティから発行されている場合は、アカウント提供元を変更してください (例: kaisha-mei.com)。" + "matrix.org 上に無料でアカウントを作成できます。異なるアカウント提供元上にアカウントを作成したい場合は、それにならって変更してください (例: dantai-mei.org)。" + "アカウント提供元" + "example.com" "サーバーを選択" + "アカウント提供元を入力" + "アカウント提供元を選択" "アカウントを作成" "アカウントは削除されました。" "ユーザー名またはパスワードが違います" diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml index 634d856cdf..81d8570e32 100644 --- a/features/login/impl/src/main/res/values-pl/translations.xml +++ b/features/login/impl/src/main/res/values-pl/translations.xml @@ -25,8 +25,16 @@ "Dostawca konta %1$s jest niedozwolony." "URL serwera domowego" "Wprowadź adres domeny." + "Zaloguj" + "Utwórz konto" "Jaki jest adres Twojego serwera?" + "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)" + "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)." + "Dostawca konta" + "przykład.com" "Wybierz swój serwer" + "Wprowadź dostawcę swojego konta" + "Wybierz dostawcę konta" "Utwórz konto" "To konto zostało usunięte." "Nieprawidłowa nazwa użytkownika i/lub hasło" diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index b44242abf8..6835c0a15d 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -25,8 +25,16 @@ "Furnizorul de cont %1$s nu este permis." "Adresa URL a homeserver-ului" "Introduceți o adresă de domeniu." + "Autentificare" + "Creați un cont" "Care este adresa serverului dumneavoastră?" + "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)." + "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)." + "Furnizor de cont" + "exemplu.ro" "Selectați serverul dumneavoastra" + "Introduceți furnizorul contului dumneavoastră" + "Alegeți un furnizor de cont" "Creați un cont" "Acest cont a fost șters." "Utilizator și/sau parolă incorecte" diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml index 60959c80d4..918caf5530 100644 --- a/features/login/impl/src/main/res/values-uk/translations.xml +++ b/features/login/impl/src/main/res/values-uk/translations.xml @@ -25,8 +25,16 @@ "Постачальник облікового запису %1$s не дозволений." "URL-адреса домашнього сервера" "Введіть адресу домену." + "Увійти" + "Створити обліковий запис" "Яка адреса вашого сервера?" + "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)." + "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)." + "Постачальник облікового запису" + "example.com" "Виберіть свій сервер" + "Вкажіть постачальника вашого облікового запису" + "Оберіть постачальника облікового запису" "Створити обліковий запис" "Цей обліковий запис було видалено." "Неправильне ім\'я користувача та/або пароль" diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml index 16afe7d5bd..85de302dc1 100644 --- a/features/login/impl/src/main/res/values-zh/translations.xml +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -25,8 +25,16 @@ "账户提供者 %1$s 不被允许。" "主服务器 URL" "输入域名地址。" + "登录" + "创建账户" "你的服务器地址是什么?" + "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。" + "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。" + "账户提供者" + "example.com" "选择服务器" + "输入你的账户提供者" + "选择账户提供者" "创建账户" "此账户已被删除。" "用户名与(或)密码不正确" diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 10fb6ef04a..7e798081f5 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -25,8 +25,16 @@ "Account provider %1$s not allowed." "Homeserver URL" "Enter a domain address." + "Sign in" + "Create account" "What is the address of your server?" + "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." + "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." + "Account provider" + "example.com" "Select your server" + "Enter your account provider" + "Choose an account provider" "Create account" "This account has been deleted." "Incorrect username and/or password" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index a05194d008..8a0bf4c307 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -12,12 +12,11 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.api.LoginEntryPoint -import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.features.preferences.test.FakePreferencesEntryPoint -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode import kotlinx.coroutines.test.runTest @@ -38,7 +37,7 @@ class DefaultLoginEntryPointTest { LoginFlowNode( buildContext = buildContext, plugins = plugins, - accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource = anAccountProviderDataSource(), oAuthActionFlow = FakeOAuthActionFlow(), appCoroutineScope = backgroundScope, elementClassicConnection = FakeElementClassicConnection(), diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt index ee4cea0532..fbfae984fc 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt @@ -11,15 +11,9 @@ package io.element.android.features.login.impl.accesscontrol import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.changeserver.AccountProviderAccessException -import io.element.android.features.wellknown.test.FakeWellknownRetriever -import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL -import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Test @@ -30,9 +24,7 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = true, - ), + enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), ) accessControl.expectNeedElementProException() } @@ -43,9 +35,7 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = false, // false here. isAllowedToConnectToHomeserver = false, - elementWellKnown = anElementWellKnown( - enforceElementPro = true, - ), + enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), ) accessControl.expectNeedElementProException() } @@ -55,8 +45,9 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false } ), ) accessControl.expectAllowed() @@ -67,19 +58,15 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = null, - ), ) accessControl.expectAllowed() } @Test - fun `foss build should allow using account provider twith missing wellknown`() = runTest { + fun `foss build should allow using account provider with missing wellknown`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = null, ) accessControl.expectAllowed() } @@ -90,8 +77,10 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = false, isAllowedToConnectToHomeserver = false, allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + isElementProEnforcedResult = { false }, + defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -102,8 +91,9 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = true, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { true }, ), ) accessControl.expectAllowed() @@ -114,8 +104,9 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), ) accessControl.expectAllowed() @@ -127,8 +118,10 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = true, isAllowedToConnectToHomeserver = false, allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), - elementWellKnown = anElementWellKnown( - enforceElementPro = true, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + isElementProEnforcedResult = { true }, + defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -140,8 +133,10 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = true, isAllowedToConnectToHomeserver = false, allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + isElementProEnforcedResult = { false }, + defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -151,25 +146,14 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild: Boolean = false, isAllowedToConnectToHomeserver: Boolean = false, allowedAccountProviders: List = emptyList(), - elementWellKnown: ElementWellKnown? = null, - ) = DefaultAccountProviderAccessControl( - enterpriseService = FakeEnterpriseService( - isEnterpriseBuild = isEnterpriseBuild, + enterpriseService: FakeEnterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, defaultHomeserverListResult = { allowedAccountProviders }, - ), - wellknownRetrieverFactory = FakeWellknownRetrieverFactory( - wellknownRetriever = FakeWellknownRetriever( - getElementWellKnownResult = { _, _ -> - if (elementWellKnown == null) { - WellknownRetrieverResult.NotFound - } else { - WellknownRetrieverResult.Success(elementWellKnown) - } - }, - ), - ), - temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), + isElementProEnforcedResult = { false }, + ) + ) = DefaultAccountProviderAccessControl( + isEnterpriseBuild = { isEnterpriseBuild }, + enterpriseService = enterpriseService, ) private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt new file mode 100644 index 0000000000..25bc67144b --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +fun anAccountProviderDataSource( + enterpriseService: EnterpriseService = FakeEnterpriseService(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined), +) = AccountProviderDataSource( + enterpriseService = enterpriseService, + appPreferencesStore = appPreferencesStore, + coroutineScope = coroutineScope, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt index f86df13335..30e25c8c3e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -24,7 +25,7 @@ class AccountProviderDataSourceTest { @Test fun `present - initial state`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState).isEqualTo( @@ -41,10 +42,10 @@ class AccountProviderDataSourceTest { @Test fun `present - initial state - matrix org`() = runTest { - val sut = AccountProviderDataSource( - FakeEnterpriseService( + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) } - ) + ), ) sut.flow.test { val initialState = awaitItem() @@ -62,10 +63,10 @@ class AccountProviderDataSourceTest { @Test fun `present - ensure that default homeserver is not star char`() = runTest { - val sut = AccountProviderDataSource( - FakeEnterpriseService( + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) } - ) + ), ) sut.flow.test { val initialState = awaitItem() @@ -83,7 +84,7 @@ class AccountProviderDataSourceTest { @Test fun `present - user change and reset`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) @@ -106,7 +107,7 @@ class AccountProviderDataSourceTest { @Test fun `present - set url and reset`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) @@ -126,4 +127,47 @@ class AccountProviderDataSourceTest { assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) } } + + @Test + fun `present - defaults to the most recently used provider from history`() = runTest { + val sut = anAccountProviderDataSource( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com", "https://matrix.org"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://example.com") + } + } + + @Test + fun `present - history is ignored when the account provider is enforced`() = runTest { + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf("https://enforced.org") } + ), + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://enforced.org") + } + } + + @Test + fun `present - reset returns to the most recently used provider from history`() = runTest { + val sut = anAccountProviderDataSource( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://example.com") + sut.setUrl("https://other.com") + assertThat(awaitItem().url).isEqualTo("https://other.com") + sut.reset() + assertThat(awaitItem().url).isEqualTo("https://example.com") + } + } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt new file mode 100644 index 0000000000..0af8188f3f --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SaveAccountProviderToHistoryTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `invoke saves the current account provider url to history`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val accountProviderDataSource = anAccountProviderDataSource() + accountProviderDataSource.setUrl("https://example.com") + val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore) + + sut() + + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com") + } + + @Test + fun `invoke saves the selected provider even after the exposed default has been reset`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val accountProviderDataSource = anAccountProviderDataSource(appPreferencesStore = appPreferencesStore) + accountProviderDataSource.setUrl("https://example.com") + // Simulate the login flow being torn down (e.g. across an OAuth round-trip), which reverts the + // exposed default; the user's selection must still be the one saved to history. + accountProviderDataSource.reset() + val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore) + + sut() + + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com") + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index c28fee77ad..ad997111e2 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -14,24 +14,18 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate -import io.element.android.features.wellknown.test.FakeWellknownRetriever -import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -64,6 +58,7 @@ class ChangeServerPresenterTest { authenticationService = authenticationService, enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), ).test { val initialState = awaitItem() @@ -72,7 +67,7 @@ class ChangeServerPresenterTest { val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val successState = awaitItem() - assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true))) } } @@ -86,6 +81,7 @@ class ChangeServerPresenterTest { createPresenter( enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), authenticationService = authenticationService, ).test { @@ -113,6 +109,7 @@ class ChangeServerPresenterTest { createPresenter( enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), authenticationService = authenticationService, ).test { @@ -136,6 +133,7 @@ class ChangeServerPresenterTest { enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult, defaultHomeserverListResult = { listOf("element.io") }, + isElementProEnforcedResult = { false }, ), ).test { val initialState = awaitItem() @@ -159,23 +157,19 @@ class ChangeServerPresenterTest { @Test fun `present - change server element pro required error`() = runTest { - val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource -> - assertThat(homeserver).isEqualTo(A_HOMESERVER_URL) - WellknownRetrieverResult.Success( - anElementWellKnown( - enforceElementPro = true, - ) - ) - } + val isEnterpriseBuildLambda = lambdaRecorder { false } createPresenter( - wellknownRetriever = FakeWellknownRetriever( - getElementWellKnownResult = getElementWellKnownResult, - ), + isEnterpriseBuild = isEnterpriseBuildLambda, + enterpriseService = FakeEnterpriseService( + isElementProEnforcedResult = { true }, + ) ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider)) + // Skip loading state + skipItems(1) val failureState = awaitItem() assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle @@ -183,7 +177,7 @@ class ChangeServerPresenterTest { assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId ).isEqualTo("io.element.enterprise") - getElementWellKnownResult.assertions().isCalledOnce() + isEnterpriseBuildLambda.assertions().isCalledOnce() } } @@ -237,7 +231,7 @@ class ChangeServerPresenterTest { val permissionsPresenter = FakePermissionsPresenter() createPresenter( authenticationService = authenticationService, - enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }), + enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }, isElementProEnforcedResult = { false }), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), permissionsPresenter = permissionsPresenter, ).test { @@ -251,24 +245,23 @@ class ChangeServerPresenterTest { while (finalState.changeServerAction !is AsyncData.Success) { finalState = awaitItem() } - assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true))) } } private fun createPresenter( authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), enterpriseService: EnterpriseService = FakeEnterpriseService(), - wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(), + isEnterpriseBuild: () -> Boolean = { false }, ) = ChangeServerPresenter( authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = enterpriseService, - wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever), - temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), + isEnterpriseBuild = isEnterpriseBuild, ), localNetworkPermissionGate = LocalNetworkPermissionGate( advisor = localNetworkPermissionAdvisor, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt deleted file mode 100644 index 38e04b6e7f..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.resolver - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker -import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class HomeserverResolverTest { - @Test - fun `resolve - a server name is used as is`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("example.org").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org")), - ) - } - - @Test - fun `resolve - an url is used as is`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("https://example.org").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org")), - ) - } - - @Test - fun `resolve - a user id resolves the server name of the user id`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice:example.org").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org")), - ) - } - - @Test - fun `resolve - a user id with a port resolves the server name of the user id`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org:8448") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice:example.org:8448").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org:8448")), - ) - } - - @Test - fun `resolve - an incomplete user id does not resolve anything`() = runTest { - val checkResult = lambdaRecorder> { Result.success(true) } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice").test { - awaitComplete() - } - sut.resolve("@alice:").test { - awaitComplete() - } - checkResult.assertions().isNeverCalled() - } - - @Test - fun `resolve - a user id with a too short server name does not resolve anything`() = runTest { - val checkResult = lambdaRecorder> { Result.success(true) } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice:a.b").test { - awaitComplete() - } - checkResult.assertions().isNeverCalled() - } - - private fun TestScope.createHomeserverResolver( - checkResult: (String) -> Result, - ) = HomeserverResolver( - dispatchers = testCoroutineDispatchers(), - homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult), - localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), - ) -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt deleted file mode 100644 index 7d81f05c0c..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER -import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.test -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class ChangeAccountProviderPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { emptyList() } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isTrue() - } - } - - @Test - fun `present - fixed list of account providers`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { - listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2) - } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ), - AccountProvider( - url = "https://element.io", - title = "element.io", - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isFalse() - } - } - - @Test - fun `present - opened list of account providers`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { - listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER) - } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isTrue() - } - } -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt index 8d56386fa4..d4a1a74d99 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt @@ -8,8 +8,8 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.features.login.impl.classic.ElementClassicConnectionState import io.element.android.features.login.impl.classic.FakeElementClassicConnection @@ -249,7 +249,7 @@ private fun createPresenter( navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(), loginModePresenter: LoginModePresenter = createLoginModePresenter(), elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), isEnterpriseBuild: Boolean = false, ) = LoginWithClassicPresenter( userId = userId, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 52858b77af..641eea1563 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -8,10 +8,16 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter @@ -22,7 +28,12 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow +import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest @@ -40,7 +51,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.submitEnabled).isTrue() - assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.accountProviderInput).isEqualTo("matrix.org") assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -57,14 +68,10 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) + cancelAndIgnoreRemainingEvents() } } @@ -80,14 +87,10 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -105,18 +108,14 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenOAuthCancelError(AN_EXCEPTION) defaultOAuthActionFlow.post(OAuthAction.GoBack()) - val cancelFailureState = awaitItem() + val cancelFailureState = awaitLoginMode { it is AsyncData.Failure } assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -134,17 +133,13 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOAuthActionFlow.post(OAuthAction.GoBack()) - val cancelFinalState = awaitItem() + val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -162,13 +157,12 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + awaitLoginMode { it is AsyncData.Loading } defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) - val cancelFinalState = awaitItem() + val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -186,20 +180,14 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenLoginError(AN_EXCEPTION) defaultOAuthActionFlow.post(OAuthAction.Success("aUrl")) - val cancelLoadingState = awaitItem() - assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val cancelFailureState = awaitItem() + val cancelFailureState = awaitLoginMode { it is AsyncData.Failure } assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -217,17 +205,13 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) - val successSuccessState = awaitItem() + val successSuccessState = awaitLoginMode { it is AsyncData.Loading } assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -243,11 +227,11 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val failureState = awaitItem() - assertThat(failureState.submitEnabled).isFalse() - assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + // The account provider validation fails, so the login is never attempted. + val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } + assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) + cancelAndIgnoreRemainingEvents() } } @@ -264,18 +248,15 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() - // Submit will return an error - initialState.eventSink(ConfirmAccountProviderEvents.Continue) - - skipItems(1) // Loading + // Submit will return an error while validating the account provider + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // Check an error was returned - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } // Assert the error is then cleared submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) - val clearedState = awaitItem() + val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized } assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -284,7 +265,7 @@ class ConfirmAccountProviderPresenterTest { fun `present - confirm account creation without oidc generates an error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( setHomeserverResult = { - Result.success(aMatrixHomeServerDetails()) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, ) val presenter = createConfirmAccountProviderPresenter( @@ -293,15 +274,15 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // Check an error was returned - val submittedState = awaitItem() + val submittedState = awaitLoginMode { it is AsyncData.Failure } assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) // Assert the error is then cleared submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) - val clearedState = awaitItem() + val clearedState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) + cancelAndIgnoreRemainingEvents() } } @@ -318,25 +299,286 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - offers an autocomplete suggestion from the account provider history`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://randomcommunity.org"), + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviderSuggestion).isNull() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderInput == "random" } + // Completion is offered without the https:// scheme, even though history stores the full URL. + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org") + } + } + + @Test + fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://randomcommunity.org"), + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderSuggestion != null } + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue applies the completed account provider back into the field`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderSuggestion != null } + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org")) + // The field now renders the full accepted server rather than the typed prefix. + val appliedState = awaitState { it.accountProviderInput == "randomcommunity.org" } + assertThat(appliedState.accountProviderInput).isEqualTo("randomcommunity.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - offers the enterprise allow-list servers as autocomplete suggestions`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + enterpriseService = FakeEnterpriseService( + // Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete. + defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("ele")) + val suggestionState = awaitState { it.accountProviderInput == "ele" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("element.io") + } + } + + @Test + fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("matr")) + val suggestionState = awaitState { it.accountProviderInput == "matr" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("matrix.org") + } + } + + @Test + fun `present - does not autocomplete a scheme-prefixed input`() = runTest { + // Candidates are bare hosts, so a scheme-prefixed input never completes them - the user completes by + // typing the bare host. This also avoids ever suggesting an insecure http scheme. + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://mat")) + assertThat(awaitState { it.accountProviderInput == "https://mat" }.accountProviderSuggestion).isNull() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("http://mat")) + assertThat(awaitState { it.accountProviderInput == "http://mat" }.accountProviderSuggestion).isNull() + } + } + + @Test + fun `present - continue prepends https to a bare account provider`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue configures the homeserver only once`() = runTest { + // The consolidated Continue flow validates the account provider and then signs in. Login reuses the + // details resolved during validation, so setHomeserver runs exactly once rather than once per phase. + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a full matrix id signs in to its homeserver and passes a login hint`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://example.org") + assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a matrix id keeps the port in the homeserver`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org:8448")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://example.org:8448") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a plain account provider passes no login hint`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) }, + ) + val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(authenticationService.getOAuthUrlLoginHint).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue with a matrix id during account creation passes no login hint`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) }, + ) + val presenter = createConfirmAccountProviderPresenter( + params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(authenticationService.getOAuthUrlLoginHint).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate + * account-provider validation states, and returns that state. + */ + private suspend fun ReceiveTurbine.awaitLoginMode( + predicate: (AsyncData) -> Boolean, + ): ConfirmAccountProviderState = awaitState { predicate(it.loginModeState.loginMode) } + + private suspend fun ReceiveTurbine.awaitState( + predicate: (ConfirmAccountProviderState) -> Boolean, + ): ConfirmAccountProviderState { + while (true) { + val item = awaitItem() + if (predicate(item)) return item } } private fun createConfirmAccountProviderPresenter( params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), ) = ConfirmAccountProviderPresenter( params = params, accountProviderDataSource = accountProviderDataSource, + appPreferencesStore = appPreferencesStore, + enterpriseService = enterpriseService, loginModePresenter = createLoginModePresenter( authenticationService = matrixAuthenticationService, oAuthActionFlow = defaultOAuthActionFlow, ), + changeServerPresenter = ChangeServerPresenter( + authenticationService = matrixAuthenticationService, + accountProviderDataSource = accountProviderDataSource, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, + ), + isEnterpriseBuild = { false }, + ), + localNetworkPermissionGate = LocalNetworkPermissionGate( + advisor = FakeLocalNetworkPermissionAdvisor(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()), + ), + ), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 31a835cb8c..4dd2859dee 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -10,8 +10,9 @@ package io.element.android.features.login.impl.screens.loginpassword import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EXCEPTION @@ -21,8 +22,11 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME_2 import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -101,6 +105,32 @@ class LoginPasswordPresenterTest { } } + @Test + fun `present - successful login saves the account provider to history`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + val appPreferencesStore = InMemoryAppPreferencesStore() + createLoginPasswordPresenter( + authenticationService = authenticationService, + appPreferencesStore = appPreferencesStore, + ).test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + skipItems(1) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID)) + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + } + } + @Test fun `present - submit with error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( @@ -157,10 +187,12 @@ class LoginPasswordPresenterTest { private fun createLoginPasswordPresenter( initialLogin: String = "", authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), ): LoginPasswordPresenter = LoginPasswordPresenter( initialLogin = initialLogin, authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, + saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt index 3a128f2e4f..d76c94d5ea 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt @@ -81,6 +81,21 @@ class LoginPasswordViewTest : RobolectricTest() { ) } + @Test + fun `changing login removes carriage returns the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val userNameHint = activity!!.getString(CommonStrings.common_username) + onNodeWithText(userNameHint).performTextInput("a\r\nb") + eventsRecorder.assertSingle( + LoginPasswordEvents.SetLogin("ab") + ) + } + @Test fun `clearing login invokes the expected event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -112,6 +127,21 @@ class LoginPasswordViewTest : RobolectricTest() { ) } + @Test + fun `changing password removes carriage returns the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val passwordHint = activity!!.getString(CommonStrings.common_password) + onNodeWithText(passwordHint).performTextInput("a\r\nb") + eventsRecorder.assertSingle( + LoginPasswordEvents.SetPassword("ab") + ) + } + @Test fun `reveal password makes the password visible`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index fbee19f05e..3c2e4b574f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -12,13 +12,14 @@ import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.OnBoardingConfig import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.login.LoginModePresenter -import io.element.android.features.wellknown.test.FakeWellknownRetriever -import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -29,19 +30,18 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 import io.element.android.libraries.matrix.test.A_LOGIN_HINT -import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oauth.api.OAuthActionFlow -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData -import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.Flow @@ -186,6 +186,7 @@ class OnBoardingPresenterTest { enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), ) presenter.test { @@ -250,7 +251,7 @@ class OnBoardingPresenterTest { Result.failure(AN_EXCEPTION) }, ) - val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()) + val accountProviderDataSource = anAccountProviderDataSource() val presenter = createPresenter( params = OnBoardingNode.Params( accountProvider = A_HOMESERVER_URL, @@ -259,6 +260,7 @@ class OnBoardingPresenterTest { ), enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), loginModePresenter = createLoginModePresenter( authenticationService = authenticationService, @@ -295,20 +297,19 @@ private fun createPresenter( ), buildMeta: BuildMeta = aBuildMeta(), enterpriseService: EnterpriseService = FakeEnterpriseService(), - wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), + isEnterpriseBuild: IsEnterpriseBuild = { false }, rageshakeFeatureAvailability: () -> Flow = { flowOf(true) }, loginModePresenter: LoginModePresenter = createLoginModePresenter(), onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null }, sessionStore: SessionStore = InMemorySessionStore(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), ) = OnBoardingPresenter( params = params, buildMeta = buildMeta, enterpriseService = enterpriseService, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = enterpriseService, - wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever), - temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), + isEnterpriseBuild = isEnterpriseBuild, ), rageshakeFeatureAvailability = rageshakeFeatureAvailability, loginModePresenter = loginModePresenter, @@ -324,6 +325,8 @@ fun createLoginModePresenter( FakeLocalNetworkPermissionAdvisor(), permissionsPresenterFactory: PermissionsPresenter.Factory = FakePermissionsPresenterFactory(), + saveAccountProviderToHistory: SaveAccountProviderToHistory = + SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()), ): LoginModePresenter = LoginModePresenter( oAuthActionFlow = oAuthActionFlow, authenticationService = authenticationService, @@ -331,4 +334,5 @@ fun createLoginModePresenter( advisor = localNetworkPermissionAdvisor, permissionsPresenterFactory = permissionsPresenterFactory, ), + saveAccountProviderToHistory = saveAccountProviderToHistory, ) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt deleted file mode 100644 index 29c5d4a86c..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverResolver -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker -import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import io.element.android.tests.testutils.test -import io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class SearchAccountProviderPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.userInput).isEmpty() - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - } - } - - @Test - fun `present - error while checking login compatibility`() = runTest { - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("https://test.org") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org") - ) - ) - ) - } - } - - @Test - fun `present - enter text no result`() = runTest { - val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test"), - ) - ) - ) - } - } - - @Test - fun `present - enter text one result with wellknown`() = runTest { - val checkResult = lambdaRecorder> { - when (it) { - "https://test.org" -> Result.success(false) - "https://test.com" -> Result.success(false) - "https://test.io" -> Result.success(true) - "https://test" -> Result.success(false) - else -> error("should not happen") - } - } - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.io") - ) - ) - ) - checkResult.assertions().isCalledExactly(4) - .withSequence( - listOf(value("https://test.org")), - listOf(value("https://test.com")), - listOf(value("https://test.io")), - listOf(value("https://test")), - ) - } - } - - @Test - fun `present - enter text two results with wellknown`() = runTest { - val checkResult = lambdaRecorder> { - when (it) { - "https://test.org" -> Result.success(true) - "https://test.com" -> Result.success(false) - "https://test.io" -> Result.success(true) - "https://test" -> Result.success(false) - else -> error("should not happen") - } - } - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org"), - ) - ) - ) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org"), - aHomeserverData(homeserverUrl = "https://test.io"), - ) - ) - ) - checkResult.assertions().isCalledExactly(4) - .withSequence( - listOf(value("https://test.org")), - listOf(value("https://test.com")), - listOf(value("https://test.io")), - listOf(value("https://test")), - ) - } - } -} diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts index 7073648dd2..4cbf0910db 100644 --- a/features/login/test/build.gradle.kts +++ b/features/login/test/build.gradle.kts @@ -15,6 +15,7 @@ android { } dependencies { + implementation(projects.libraries.architecture) implementation(projects.features.login.api) implementation(projects.tests.testutils) } diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt new file mode 100644 index 0000000000..2c94144890 --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLoginEntryPoint( + private val createNodeResult: (BuildContext, LoginEntryPoint.Params) -> Node = { _, _ -> lambdaError() }, +) : LoginEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: LoginEntryPoint.Params, + callback: LoginEntryPoint.Callback, + ): Node = createNodeResult(buildContext, params) +} diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt new file mode 100644 index 0000000000..9ebd3be7fc --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.test.accesscontrol + +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountProviderAccessControl( + private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() }, +) : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean { + return isAllowedToConnectToAccountProviderResult(accountProviderUrl) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index dd3afb0ea6..4af0c05955 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -400,9 +400,23 @@ class MessagesPresenter( TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit + TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) } } + private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) { + val sendHandle = targetEvent.sendhandle ?: return Unit.also { + Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}") + } + sendHandle.retry() + .onSuccess { + Timber.d("Succeed to add the message back to the send queue") + } + .onFailure { + Timber.e(it, "Failed to add the message back to the send queue") + } + } + private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) { timelineController.invokeOnCurrentTimeline { editCaption( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index ee374e005d..16ca023d50 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView import io.element.android.features.messages.impl.topbars.MessagesViewTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog @@ -178,8 +179,8 @@ fun MessagesView( fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") - val eventId = event.eventId ?: return - if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return + val eventId = event.eventId + if (eventId != null && eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) if (hideKeyboard) { @@ -429,6 +430,31 @@ fun MessagesView( }, state = state.linkState, ) + + SendFailureDialogView( + sendFailureDialogState = state.timelineState.sendFailureDialogState, + onDismiss = { + state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) + }, + onRetry = { event -> + state.eventSink( + MessagesEvent.HandleAction( + action = TimelineItemAction.RetrySending, + event = event, + ) + ) + state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) + }, + onRemoveMessage = { event -> + state.eventSink( + MessagesEvent.HandleAction( + action = TimelineItemAction.Redact, + event = event, + ) + ) + state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) + }, + ) } @Composable @@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents( when { state.successorRoom != null -> { SuccessorRoomBanner( - modifier = Modifier.fillMaxWidth().padding(contentPadding), + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick ) } state.userEventPermissions.canSendMessage -> { - Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + ) { // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). if (state.composerState.suggestions.isEmpty() && state.composerState.textEditorState is TextEditorState.Markdown) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 25e75beb99..3c64223e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -36,4 +36,7 @@ enum class TimelineItemAction( EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end), Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), + + // Note: this action is not used in UI for now + RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid), } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 0a0d9b17af..2f6fa4e4e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.ViewSource, TimelineItemAction.ReportContent, TimelineItemAction.Redact, + // Not rendered in the action list, but has to be listed here for exhaustiveness. + TimelineItemAction.RetrySending, ) override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 5f12bf4be1..2974b99696 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -86,6 +86,7 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.services.analytics.api.AnalyticsService @@ -103,7 +104,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -459,6 +462,14 @@ class MessageComposerPresenter( val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger) + // Refresh the room members, which are only loaded on demand, when a mention starts + launch { + suggestionSearchTrigger + .distinctUntilChangedBy { it?.type } + .filter { it?.type == SuggestionType.Mention } + .collect { room.updateMembers() } + } + val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource .getAllRoomAliasSuggestions() .stateIn(this, SharingStarted.Lazily, emptyList()) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt index 73ba08b29d..9be11d244a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt @@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.pinned.list import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId sealed interface PinnedMessagesListEvent { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent + data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : PinnedMessagesListEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 06d97f167c..36ae68de9b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId @@ -50,6 +51,7 @@ class PinnedMessagesListNode( actionListPresenterFactory: ActionListPresenter.Factory, private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val permalinkParser: PermalinkParser, + private val emojiPickerRenderer: EmojiPickerRenderer, ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) @@ -134,6 +136,7 @@ class PinnedMessagesListNode( toastMessage = toastMessage, ) }, + emojiPickerRenderer = emojiPickerRenderer, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index e1ee18e920..748fa084f8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -30,6 +30,8 @@ import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -47,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -71,6 +74,8 @@ class PinnedMessagesListPresenter( private val linkPresenter: Presenter, private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val actionListPresenter: Presenter, + private val customReactionPresenter: Presenter, + private val reactionSummaryPresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val analyticsService: AnalyticsService, @@ -88,7 +93,7 @@ class PinnedMessagesListPresenter( private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( config = TimelineItemsFactoryConfig( computeReadReceipts = false, - computeReactions = false, + computeReactions = true, ) ) @@ -96,6 +101,9 @@ class PinnedMessagesListPresenter( override fun present(): PinnedMessagesListState { htmlConverterProvider.Update() val roomInfo by room.roomInfoFlow.collectAsState() + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val timelineRoomInfo by remember { derivedStateOf { TimelineRoomInfo( @@ -103,7 +111,7 @@ class PinnedMessagesListPresenter( name = roomInfo.name, // We don't need to compute those values userHasPermissionToSendMessage = false, - userHasPermissionToSendReaction = false, + userHasPermissionToSendReaction = userEventPermissions.canSendReaction, // We do not care about the call state here. roomCallState = aStandByCallState(), // don't compute this value or the pin icon will be shown @@ -119,9 +127,8 @@ class PinnedMessagesListPresenter( } val timelineProtectionState = timelineProtectionPresenter.present() val linkState = linkPresenter.present() - val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> - perms.userEventPermissions() - } + val customReactionState = customReactionPresenter.present() + val reactionSummaryState = reactionSummaryPresenter.present() val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) @@ -138,6 +145,7 @@ class PinnedMessagesListPresenter( when (event) { is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event) is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId) + is PinnedMessagesListEvent.ToggleReaction -> sessionCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } } @@ -148,10 +156,20 @@ class PinnedMessagesListPresenter( displayThreadSummaries = displayThreadSummaries, userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, eventSink = ::handleEvent, ) } + private fun CoroutineScope.toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId) { + launch { + timelineProvider.invokeOnTimeline { + toggleReaction(emoji, eventOrTransactionId) + } + } + } + private fun CoroutineScope.handleTimelineAction( action: TimelineItemAction, targetEvent: TimelineItem.Event, @@ -234,6 +252,8 @@ class PinnedMessagesListPresenter( linkState: LinkState, userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, + customReactionState: CustomReactionState, + reactionSummaryState: ReactionSummaryState, eventSink: (PinnedMessagesListEvent) -> Unit ): PinnedMessagesListState { return when (timelineItems) { @@ -252,6 +272,8 @@ class PinnedMessagesListPresenter( userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, eventSink = eventSink ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index bf37029eff..6c55a0d90a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.ui.strings.CommonPlurals @@ -34,6 +36,8 @@ sealed interface PinnedMessagesListState { val timelineItems: ImmutableList, val actionListState: ActionListState, val linkState: LinkState, + val customReactionState: CustomReactionState, + val reactionSummaryState: ReactionSummaryState, val displayThreadSummaries: Boolean, val eventSink: (PinnedMessagesListEvent) -> Unit, ) : PinnedMessagesListState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt index 3391f6847f..3d01906df8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.link.LinkState @@ -19,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent @@ -92,6 +96,8 @@ fun aLoadedPinnedMessagesListState( linkState: LinkState = aLinkState(), timelineItems: List = emptyList(), actionListState: ActionListState = anActionListState(), + customReactionState: CustomReactionState = aCustomReactionState(), + reactionSummaryState: ReactionSummaryState = aReactionSummaryState(), aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, displayThreadSummaries: Boolean = false, eventSink: (PinnedMessagesListEvent) -> Unit = {} @@ -101,6 +107,8 @@ fun aLoadedPinnedMessagesListState( linkState = linkState, timelineItems = timelineItems.toImmutableList(), actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, userEventPermissions = aUserEventPermissions, displayThreadSummaries = displayThreadSummaries, eventSink = eventSink, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index e13fb1a45e..5daec3e275 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -33,8 +33,12 @@ import io.element.android.features.messages.impl.link.LinkEvent import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState @@ -50,6 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.ui.strings.CommonStrings @@ -66,6 +72,7 @@ fun PinnedMessagesListView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { Scaffold( @@ -89,6 +96,7 @@ fun PinnedMessagesListView( onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, onErrorDismiss = onBackClick, + emojiPickerRenderer = emojiPickerRenderer, modifier = Modifier .padding(padding) .consumeWindowInsets(padding), @@ -121,6 +129,7 @@ private fun PinnedMessagesListContent( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onErrorDismiss: () -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { Box(modifier.fillMaxSize()) { @@ -141,6 +150,7 @@ private fun PinnedMessagesListContent( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, + emojiPickerRenderer = emojiPickerRenderer, ) PinnedMessagesListState.Loading -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -180,6 +190,7 @@ private fun PinnedMessagesListLoaded( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { @@ -203,11 +214,25 @@ private fun PinnedMessagesListLoaded( ) } + fun onReactionClick(emoji: String, event: TimelineItem.Event) { + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, event.eventOrTransactionId)) + } + + fun onReactionLongClick(emoji: String, event: TimelineItem.Event) { + event.eventId?.let { eventId -> + state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(eventId, event.reactionsState.reactions, emoji)) + } + } + + fun onMoreReactionsClick(event: TimelineItem.Event) { + state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event)) + } + ActionListView( state = state.actionListState, onSelectAction = ::onActionSelected, - onCustomReactionClick = {}, - onEmojiReactionClick = { _, _ -> }, + onCustomReactionClick = ::onMoreReactionsClick, + onEmojiReactionClick = ::onReactionClick, onVerifiedUserSendFailureClick = {} ) @@ -239,9 +264,9 @@ private fun PinnedMessagesListLoaded( onLongClick = ::onMessageLongClick, displayThreadSummaries = displayThreadSummaries, inReplyToClick = {}, - onReactionClick = { _, _ -> }, - onReactionLongClick = { _, _ -> }, - onMoreReactionsClick = {}, + onReactionClick = ::onReactionClick, + onReactionLongClick = ::onReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, onReadReceiptClick = {}, onSwipeToReply = {}, onJoinCallClick = {}, @@ -273,6 +298,14 @@ private fun PinnedMessagesListLoaded( state.linkState, onLinkValid = onLinkClick, ) + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { eventOrTransactionId, emoji -> + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId)) + }, + emojiPickerRenderer = emojiPickerRenderer, + ) + ReactionSummaryView(state = state.reactionSummaryState) } @Composable @@ -322,5 +355,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS onUserDataClick = {}, onLinkClick = {}, onLinkLongClick = {}, + emojiPickerRenderer = NoOpEmojiPickerRenderer, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index cfc3672ca3..97608e15fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -34,6 +34,9 @@ sealed interface TimelineEvent { */ sealed interface TimelineItemEvent : TimelineEvent + data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent + data object HideSendFailureDialog : TimelineEvent + data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 7cd0d018d2..43913c43bb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems @@ -142,6 +144,7 @@ class TimelinePresenter( val newEventState = remember { mutableStateOf(NewEventState.None) } val messageShieldDialogData: MutableState = remember { mutableStateOf(null) } + var sendFailureDialogState by remember { mutableStateOf(SendFailureDialogState.Hidden) } // Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a // [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for @@ -166,9 +169,14 @@ class TimelinePresenter( fun handleEvent(event: TimelineEvent) { when (event) { is TimelineEvent.LoadMore -> { - if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) { - // Do not paginate forwards in thread mode, as it's not supported - return + if (event.direction == Timeline.PaginationDirection.FORWARDS) { + if (timelineMode is Timeline.Mode.Thread) { + // Do not paginate forwards in thread mode, as it's not supported + return + } + if (focusRequestState.value.isPending()) { + return + } } localScope.launch { timelineController.paginate(direction = event.direction) @@ -244,6 +252,47 @@ class TimelinePresenter( suppressJumpToUnread.value = true } is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData + is TimelineEvent.ShowSendFailureDialog -> { + val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return + when (sendStateFailure) { + is LocalEventSendState.Failed.VerifiedUser -> { + // defer to the resolveVerifiedUserSendFailureState to handle this case, + // as it will show a dialog with the right information + resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) + } + is LocalEventSendState.Failed.InvalidMimeType -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType), + ) + } + LocalEventSendState.Failed.MissingMediaContent -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, + ) + } + LocalEventSendState.Failed.SendingFromUnverifiedDevice -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, + ) + } + is LocalEventSendState.Failed.Unknown -> { + sendFailureDialogState = SendFailureDialogState.Show( + event = event.event, + sendFailureType = if (sendStateFailure.error.isNotBlank()) { + SendFailureDialogState.SendFailureType.Error(sendStateFailure.error) + } else { + SendFailureDialogState.SendFailureType.Unknown + } + ) + } + } + } + is TimelineEvent.HideSendFailureDialog -> { + sendFailureDialogState = SendFailureDialogState.Hidden + } is TimelineEvent.ComputeVerifiedUserSendFailure -> { resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) } @@ -408,6 +457,7 @@ class TimelinePresenter( focusRequestState = focusRequestState.value, messageShieldDialogData = messageShieldDialogData.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + sendFailureDialogState = sendFailureDialogState, displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, @@ -537,6 +587,16 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState { } } +private fun FocusRequestState.isPending(): Boolean { + return when (this) { + is FocusRequestState.Requested, + is FocusRequestState.Loading -> true + is FocusRequestState.Success -> !rendered + FocusRequestState.None, + is FocusRequestState.Failure -> false + } +} + // Workaround for not having the server names available, get possible server names from the user ids of the room members private fun calculateServerNamesForRoom(room: JoinedRoom): List { // If we have no room members, return right ahead diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 7836c17563..1ebc96f15f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso import io.element.android.features.messages.impl.timeline.components.MessageShieldData import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.matrix.api.core.EventId @@ -32,6 +33,7 @@ data class TimelineState( // If not null, info will be rendered in a dialog val messageShieldDialogData: MessageShieldData?, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, + val sendFailureDialogState: SendFailureDialogState, val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 61b5c690a4..89545b5d0b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.roomcall.api.aStandByCallState @@ -57,6 +58,7 @@ fun aTimelineState( isLive: Boolean = true, messageShield: MessageShield? = null, resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), + sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden, displayThreadSummaries: Boolean = false, displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, @@ -78,6 +80,7 @@ fun aTimelineState( focusRequestState = focusRequestState, messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + sendFailureDialogState = sendFailureDialogState, displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index 1314e8cefa..d7492ca40b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -56,10 +56,11 @@ fun TimelineEventTimestampView( val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val onClickLabel = when { shield != null -> stringResource(CommonStrings.a11y_view_details) - hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) + isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) + hasError -> stringResource(CommonStrings.a11y_view_details) else -> null } - val clickableModifier = remember(shield, hasError) { + val clickableModifier = remember(event) { when { shield != null -> { Modifier.clickable( @@ -70,10 +71,9 @@ fun TimelineEventTimestampView( } hasError -> Modifier .clickable( - enabled = isVerifiedUserSendFailure, onClickLabel = onClickLabel, ) { - eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event)) + eventSink(TimelineEvent.ShowSendFailureDialog(event)) } else -> Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index 1679a6030a..9bf7aa3c32 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -21,6 +21,9 @@ import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -110,7 +113,11 @@ class TimelineItemsFactory( ): TimelineItem? { val timelineItem = when (val currentTimelineItem = timelineItems[index]) { - is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts) + is MatrixTimelineItem.Event -> if (currentTimelineItem.event.isKeyVerificationRequest()) { + null + } else { + eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts) + } is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) MatrixTimelineItem.Other -> null } @@ -118,3 +125,8 @@ class TimelineItemsFactory( return timelineItem } } + +private fun EventTimelineItem.isKeyVerificationRequest(): Boolean { + val messageType = (content as? MessageContent)?.type + return messageType is OtherMessageType && messageType.isKeyVerificationRequest +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt new file mode 100644 index 0000000000..02da5a6503 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +@Immutable +sealed interface SendFailureDialogState { + data object Hidden : SendFailureDialogState + + data class Show( + val event: TimelineItem.Event, + val sendFailureType: SendFailureType, + ) : SendFailureDialogState + + @Immutable + sealed interface SendFailureType { + data class InvalidMimeType(val mimeType: String) : SendFailureType + data object MissingMediaContent : SendFailureType + data object SendingFromUnverifiedDevice : SendFailureType + data class Error(val message: String) : SendFailureType + data object Unknown : SendFailureType + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStatePreviewParam.kt new file mode 100644 index 0000000000..728431cc44 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStatePreviewParam.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent + +class SendFailureDialogStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SendFailureDialogState.Hidden, + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("image/invalid") + ), + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, + ), + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, + ), + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + sendFailureType = SendFailureDialogState.SendFailureType.Error("Message error from server"), + ), + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + sendFailureType = SendFailureDialogState.SendFailureType.Unknown, + ) + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt new file mode 100644 index 0000000000..15ee42d575 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SendFailureDialogView( + sendFailureDialogState: SendFailureDialogState, + onDismiss: () -> Unit, + onRetry: (TimelineItem.Event) -> Unit, + onRemoveMessage: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + when (sendFailureDialogState) { + is SendFailureDialogState.Hidden -> Unit + is SendFailureDialogState.Show -> { + // Show the dialog with the message and actions + val content = when (sendFailureDialogState.sendFailureType) { + is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource( + id = R.string.error_sending_failure_invalid_mime_type, + sendFailureDialogState.sendFailureType.mimeType, + ) + is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource( + id = R.string.error_sending_failure_missing_media_content, + ) + is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource( + id = R.string.error_sending_failure_sending_from_unverified_device, + ) + is SendFailureDialogState.SendFailureType.Error -> stringResource( + id = R.string.error_sending_failure_other, + sendFailureDialogState.sendFailureType.message + ) + is SendFailureDialogState.SendFailureType.Unknown -> stringResource( + id = R.string.error_sending_failure_unknown, + ) + } + + ConfirmationDialog( + modifier = modifier, + title = stringResource(id = CommonStrings.common_sending_failed), + content = content, + onDismiss = onDismiss, + submitText = stringResource(id = CommonStrings.action_retry), + onSubmitClick = { onRetry(sendFailureDialogState.event) }, + onCancelClick = onDismiss, + thirdButtonText = stringResource(id = CommonStrings.action_remove_message), + onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) }, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SendFailureDialogViewPreview( + @PreviewParameter(SendFailureDialogStatePreviewParam::class) sendFailureDialogState: SendFailureDialogState, +) = ElementPreview { + SendFailureDialogView( + sendFailureDialogState = sendFailureDialogState, + onDismiss = {}, + onRetry = {}, + onRemoveMessage = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index 6aabf97f2c..d6a55c0563 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -84,6 +85,7 @@ internal fun MessagesViewTopBar( val roundedCornerShape = RoundedCornerShape(8.dp) Row( modifier = Modifier + .heightIn(min = 48.dp) .clip(roundedCornerShape) .clickable { onRoomDetailsClick() } .semantics { heading() }, diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 556ab00ee0..e29b83af41 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -7,6 +7,11 @@ "Encrypted by an unknown or deleted device." "Encrypted by a device not verified by its owner." "Encrypted by an unverified user." + "Invalid mime type: %s" + "Missing media content" + "Error details: %s" + "Sending from unverified device" + "Unknown error" "Flip image horizontally" "Flipped horizontally" "Original" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 0a0d310b16..227088f78c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -83,6 +84,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -1043,6 +1045,56 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action retry sending`() = runTest { + val retryLambda = lambdaRecorder> { Result.success(Unit) } + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + retryLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - handle action retry sending - failure is ignored`() = runTest { + val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + retryLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest { + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { null }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + // No op! + } + } + @Test fun `present - handle action view in timeline, it should have no effect`() = runTest { val messageEvent = aMessageEvent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 2800c737ad..f662da0eb0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -55,12 +55,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.user.MatrixUser @@ -88,6 +90,7 @@ import org.junit.Test import org.robolectric.annotation.Config import kotlin.time.Duration.Companion.milliseconds +@Suppress("LargeClass") class MessagesViewTest : RobolectricTest() { @Test fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest { @@ -176,6 +179,33 @@ class MessagesViewTest : RobolectricTest() { callback.assertSuccess() } + @Test + fun `clicking on an Event which has not been sent yet invoke expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + val localEcho = aTimelineItemEvent( + isMine = true, + content = aTimelineItemTextContent(), + ).copy(eventId = null) + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = persistentListOf(localEcho), + ), + eventSink = eventsRecorder + ) + val callback = EnsureCalledOnceWithTwoParamsAndResult( + expectedParam1 = true, + expectedParam2 = localEcho, + result = true, + ) + setMessagesView( + state = state, + onEventClick = callback, + ) + // Cannot perform click on "Text", it's not detected. Use tag instead + onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() + callback.assertSuccess() + } + @Test fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() { `long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true) @@ -531,6 +561,80 @@ class MessagesViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem)) } + @Test + fun `clicking on retry in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder() + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_retry) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, timelineItem)) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + + @Test + fun `clicking on remove message in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder() + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_remove_message) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + + @Test + fun `cancelling the send failure dialog emits the expected Event`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder(expectEvents = false) + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_cancel) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + @Test fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest { val aUnicode = "🙈" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 07b9a4e330..4170ae41ab 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -1056,7 +1056,8 @@ class MessageComposerPresenterTest : RobolectricTest() { baseRoom = FakeBaseRoom( roomPermissions = FakeRoomPermissions( canTriggerRoomNotification = true, - ) + ), + updateMembersResult = {}, ), typingNoticeResult = { Result.success(Unit) } ).apply { @@ -1103,6 +1104,55 @@ class MessageComposerPresenterTest : RobolectricTest() { } } + @Test + fun `present - mention suggestions refresh the room members`() = runTest { + val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) + val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) + val updateMembersResult = lambdaRecorder { } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canTriggerRoomNotification = true, + ), + updateMembersResult = updateMembersResult, + ), + typingNoticeResult = { Result.success(Unit) } + ).apply { + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(currentUser, bob), + ) + ) + givenRoomInfo(aRoomInfo(isDirect = false)) + } + val presenter = createPresenter( + room = room, + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> emptyList() }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + skipItems(1) + assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob)) + updateMembersResult.assertions().isCalledOnce() + + // Typing after the `@` must not refresh the members again + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 1, SuggestionType.Mention, "b"))) + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 2, SuggestionType.Mention, "bo"))) + advanceUntilIdle() + updateMembersResult.assertions().isCalledOnce() + + // Starting a new mention refreshes them again + initialState.eventSink(MessageComposerEvent.SuggestionReceived(null)) + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + advanceUntilIdle() + updateMembersResult.assertions().isCalledExactly(2) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - room mention suggestions no permission`() = runTest { val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) @@ -1113,7 +1163,8 @@ class MessageComposerPresenterTest : RobolectricTest() { baseRoom = FakeBaseRoom( roomPermissions = FakeRoomPermissions( canTriggerRoomNotification = false, - ) + ), + updateMembersResult = {}, ), typingNoticeResult = { Result.success(Unit) } ).apply { @@ -1144,6 +1195,7 @@ class MessageComposerPresenterTest : RobolectricTest() { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true), + updateMembersResult = {}, ), typingNoticeResult = { Result.success(Unit) } ).apply { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 3efa707c3d..cafb16c41f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -400,6 +402,8 @@ class PinnedMessagesListPresenterTest { snackbarDispatcher = SnackbarDispatcher(), actionListPresenter = { anActionListState() }, linkPresenter = { aLinkState() }, + customReactionPresenter = { aCustomReactionState() }, + reactionSummaryPresenter = { aReactionSummaryState() }, analyticsService = analyticsService, featureFlagService = featureFlagService, sessionCoroutineScope = this, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt index 35e60684a9..856f74e33a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam @@ -111,6 +112,7 @@ private fun AndroidComposeUiTest.setPinnedMessagesListView( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, + emojiPickerRenderer = NoOpEmojiPickerRenderer, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3b0794305f..e00cec3322 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -12,6 +12,8 @@ import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.impl.FakeMessagesNavigator +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -21,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -45,6 +48,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent @@ -1059,6 +1063,169 @@ class TimelinePresenterTest { } } + @Test + fun `present - a forward pagination while the focused event is not rendered yet is ignored`() = runTest { + val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> + Result.success(true) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last() + assertThat(focusedState.isLive).isFalse() + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + assertThat(expectMostRecentItem().isLive).isFalse() + } + } + + @Test + fun `present - a forward pagination is performed once the focused event has been rendered`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(true) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) } + initialState.eventSink.invoke(TimelineEvent.OnFocusEventRender) + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.FORWARDS)) + assertThat(expectMostRecentItem().isLive).isTrue() + } + } + + @Test + fun `present - a backward pagination while the focused event is not rendered yet is performed`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.BACKWARDS)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - a forward pagination while the focus request is debounced is ignored`() = runTest { + val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> + Result.success(true) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = 1.seconds)) + // The focus request has started and is now waiting for the debounce to elapse + consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Requested } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + assertThat(expectMostRecentItem().isLive).isFalse() + } + } + + @Test + fun `present - a forward pagination while the focused timeline is loading is ignored`() = runTest { + val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection -> + Result.success(true) + } + val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + // The focused timeline is being created, the focus request has not resolved yet + consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Loading } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + assertThat(expectMostRecentItem().isLive).isFalse() + } + } + + @Test + fun `present - a forward pagination after a failed focus request is performed`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + val room = aRoomWithDetachedTimeline( + paginateLambda = paginateLambda, + createTimelineResult = { Result.failure(RuntimeException("An error")) }, + ) + val presenter = createTimelinePresenter( + room = room, + timeline = room.liveTimeline, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Failure } + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.FORWARDS)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - a forward pagination in a thread timeline is ignored`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(true) + } + val timeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply { + this.paginateLambda = paginateLambda + } + val presenter = createTimelinePresenter(timeline = timeline) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) + advanceUntilIdle() + assert(paginateLambda).isNeverCalled() + // A backward pagination is still performed in a thread timeline + initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + advanceUntilIdle() + assert(paginateLambda) + .isCalledOnce() + .with(value(Timeline.PaginationDirection.BACKWARDS)) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - focus on known event retrieves the event from cache`() = runTest { val timelineItemIndexer = TimelineItemIndexer() @@ -1390,6 +1557,112 @@ class TimelinePresenterTest { } } + @Test + fun `present - show and hide send failure dialog`() = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error")) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + awaitItem().also { state -> + assertThat(state.sendFailureDialogState).isEqualTo( + SendFailureDialogState.Show( + event = event, + sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"), + ) + ) + state.eventSink(TimelineEvent.HideSendFailureDialog) + } + awaitItem().also { state -> + assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + } + } + } + + @Test + fun `present - show send failure dialog - unknown error with a blank reason`() { + assertSendFailureType( + sendState = LocalEventSendState.Failed.Unknown(error = ""), + expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown, + ) + } + + @Test + fun `present - show send failure dialog - invalid mime type`() { + assertSendFailureType( + sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"), + expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"), + ) + } + + @Test + fun `present - show send failure dialog - missing media content`() { + assertSendFailureType( + sendState = LocalEventSendState.Failed.MissingMediaContent, + expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, + ) + } + + @Test + fun `present - show send failure dialog - sending from unverified device`() { + assertSendFailureType( + sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice, + expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, + ) + } + + private fun assertSendFailureType( + sendState: LocalEventSendState.Failed, + expectedSendFailureType: SendFailureDialogState.SendFailureType, + ) = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = sendState) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + assertThat(awaitItem().sendFailureDialogState).isEqualTo( + SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest { + val resolveEventSinkRecorder = lambdaRecorder { } + val presenter = createTimelinePresenter( + resolveVerifiedUserSendFailurePresenter = { + aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder) + }, + ) + val event = aMessageEvent( + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + // The generic dialog is not displayed, the dedicated one is. + assertThat(consumeItemsUntilTimeout()).isEmpty() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + resolveEventSinkRecorder.assertions() + .isCalledOnce() + .with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event))) + } + } + + @Test + fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + assertThat(consumeItemsUntilTimeout()).isEmpty() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + } + } + @Test fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { val timeline = FakeTimeline( @@ -1552,6 +1825,37 @@ class TimelinePresenterTest { return awaitItem() } + private fun aRoomWithDetachedTimeline( + paginateLambda: (Timeline.PaginationDirection) -> Result, + createTimelineResult: () -> Result = { Result.success(aDetachedTimeline(paginateLambda)) }, + ): FakeJoinedRoom { + return FakeJoinedRoom( + liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())).apply { + this.paginateLambda = paginateLambda + }, + createTimelineResult = { createTimelineResult() }, + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), + ) + } + + private fun aDetachedTimeline( + paginateLambda: (Timeline.PaginationDirection) -> Result, + ) = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(eventId = AN_EVENT_ID), + ) + ) + ) + ).apply { + this.paginateLambda = paginateLambda + } + private fun roomPermissions( canRedactOther: Boolean = false, canRedactOwn: Boolean = true, @@ -1589,6 +1893,7 @@ class TimelinePresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, + resolveVerifiedUserSendFailurePresenter: Presenter = { aResolveVerifiedUserSendFailureState() }, ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1602,7 +1907,7 @@ class TimelinePresenterTest { sessionPreferencesStore = sessionPreferencesStore, timelineItemIndexer = timelineItemIndexer, timelineController = TimelineController(room, timeline), - resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, + resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index f733f116d6..ac84d48c95 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType @@ -178,6 +179,46 @@ class TimelineViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) } + @Test + fun `clicking on the send failure indicator emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent( + content = aTimelineItemTextContent(), + sendState = LocalEventSendState.Failed.Unknown("Error"), + ) + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(timelineItem), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + val contentDescription = activity!!.getString(CommonStrings.common_sending_failed) + onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem)) + } + + @Test + fun `clicking on the send failure indicator of a verified user failure emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent( + content = aTimelineItemTextContent(), + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(timelineItem), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + val contentDescription = activity!!.getString(CommonStrings.common_sending_failed) + onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem)) + } + @Test fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt index 53c39ee530..f93c8f196e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt @@ -12,8 +12,10 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem @@ -82,6 +84,50 @@ class TimelineItemsFactoryTest { ).inOrder() } + @Test + fun `a key verification request is not rendered`() = runTest { + val factory = aTimelineItemsFactory( + config = TimelineItemsFactoryConfig( + computeReadReceipts = false, + computeReactions = false, + ) + ) + val items = listOf( + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-0"), + event = anEventTimelineItem( + sender = A_USER_ID, + content = aMessageContent( + body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.", + messageType = OtherMessageType( + msgType = "m.key.verification.request", + body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.", + ), + ), + ), + ), + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-1"), + event = anEventTimelineItem( + sender = A_USER_ID, + content = aMessageContent(body = "A regular message"), + ), + ), + ) + factory.timelineItems.test { + factory.replaceWith( + timelineItems = items, + roomMembers = emptyList(), + renderReadReceipts = false, + ) + val bodies = awaitItem() + .filterIsInstance() + .map { (it.content as TimelineItemTextContent).body } + assertThat(bodies).containsExactly("A regular message") + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun TestScope.groupPositionsOf(timestamps: List): List { val factory = aTimelineItemsFactory( config = TimelineItemsFactoryConfig( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt new file mode 100644 index 0000000000..05388e7227 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.setSafeContent +import org.junit.Test + +class SendFailureDialogViewTest : RobolectricTest() { + @Test + fun `hidden state renders nothing`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = SendFailureDialogState.Hidden, + ) + onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertDoesNotExist() + } + + @Test + fun `show state renders the error failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.Error("A failure reason"), + ), + ) + assertNodeWithTextIsDisplayed(CommonStrings.common_sending_failed) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_other, "A failure reason")).assertExists() + } + + @Test + fun `show state renders the invalid mime type failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"), + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_invalid_mime_type, "invalid/mimeType")).assertExists() + } + + @Test + fun `show state renders the missing media content failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_missing_media_content)).assertExists() + } + + @Test + fun `show state renders the sending from unverified device failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_sending_from_unverified_device)).assertExists() + } + + @Test + fun `show state renders the unknown failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.Unknown, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_unknown)).assertExists() + } + + @Test + fun `clicking on retry invokes the expected callback`() = runAndroidComposeUiTest { + val state = aSendFailureDialogStateShow() + ensureCalledOnceWithParam(state.event) { callback -> + setSendFailureDialogView( + sendFailureDialogState = state, + onRetry = callback, + ) + clickOn(CommonStrings.action_retry) + } + } + + @Test + fun `clicking on remove message invokes the expected callback`() = runAndroidComposeUiTest { + val state = aSendFailureDialogStateShow() + ensureCalledOnceWithParam(state.event) { callback -> + setSendFailureDialogView( + sendFailureDialogState = state, + onRemoveMessage = callback, + ) + clickOn(CommonStrings.action_remove_message) + } + } + + @Test + fun `clicking on cancel invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow(), + onDismiss = callback, + ) + clickOn(CommonStrings.action_cancel) + } + } + + private fun aSendFailureDialogStateShow( + sendFailureType: SendFailureDialogState.SendFailureType = SendFailureDialogState.SendFailureType.Unknown, + ) = SendFailureDialogState.Show( + event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("")), + sendFailureType = sendFailureType, + ) + + private fun AndroidComposeUiTest.setSendFailureDialogView( + sendFailureDialogState: SendFailureDialogState, + onDismiss: () -> Unit = EnsureNeverCalled(), + onRetry: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onRemoveMessage: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + ) { + setSafeContent { + SendFailureDialogView( + sendFailureDialogState = sendFailureDialogState, + onDismiss = onDismiss, + onRetry = onRetry, + onRemoveMessage = onRemoveMessage, + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 7d3b6cf647..fb44bb4e80 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -60,6 +60,7 @@ import org.junit.Rule import org.junit.Test import java.io.File import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") @@ -67,8 +68,14 @@ class DefaultVoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + private val startRecordResult = lambdaRecorder { } + private val stopRecordResult = lambdaRecorder { } + private val deleteRecordingResult = lambdaRecorder { } private val voiceRecorder = FakeVoiceRecorder( - recordingDuration = RECORDING_DURATION + recordingDuration = RECORDING_DURATION, + startRecordResult = startRecordResult, + stopRecordResult = stopRecordResult, + deleteRecordingResult = deleteRecordingResult, ) private val analyticsService = FakeAnalyticsService() private val sendVoiceMessageResult = @@ -97,7 +104,9 @@ class DefaultVoiceMessageComposerPresenterTest { companion object { private val RECORDING_DURATION = 1.seconds + private val FIRST_LEVEL_DURATION = 500.milliseconds private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList()) + private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList()) } @Test @@ -106,7 +115,7 @@ class DefaultVoiceMessageComposerPresenterTest { presenter.test { val initialState = awaitItem() assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() testPauseAndDestroy(initialState) } @@ -118,9 +127,10 @@ class DefaultVoiceMessageComposerPresenterTest { presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + assertThat(awaitItem().voiceMessageState).isEqualTo(FIRST_RECORDING_STATE) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -133,13 +143,18 @@ class DefaultVoiceMessageComposerPresenterTest { val voiceRecorder = FakeVoiceRecorder( levels = levels, recordingDuration = RECORDING_DURATION, + startRecordResult = { }, + stopRecordResult = { }, + deleteRecordingResult = { }, ) val presenter = createDefaultVoiceMessageComposerPresenter( voiceRecorder = voiceRecorder, ) presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) - skipItems(numberOfLevels / 2 - 1) + + // Skip until we reach the final state, which should have the last 128 levels + skipItems(numberOfLevels - 1) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java) val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording @@ -221,7 +236,8 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) - voiceRecorder.assertCalls(started = 1, stopped = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) cancelAndIgnoreRemainingEvents() } @@ -235,7 +251,9 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel)) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(true)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } } @@ -249,7 +267,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -265,8 +285,10 @@ class DefaultVoiceMessageComposerPresenterTest { } // Nothing should happen - assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0) + assertThat(finalState.voiceMessageState).isEqualTo(FIRST_RECORDING_STATE) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isNeverCalled() + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -282,7 +304,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) } - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -299,7 +323,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPausedState()) } - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -340,7 +366,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -360,7 +388,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -377,7 +407,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -455,7 +487,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -476,7 +510,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -499,7 +535,9 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true)) sendVoiceMessageResult.assertions().isNeverCalled() assertThat(analyticsService.trackedErrors).isEmpty() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -527,7 +565,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -577,7 +617,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isNeverCalled() assertThat(analyticsService.trackedErrors).hasSize(1) - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() testPauseAndDestroy(initialState) } @@ -586,8 +626,14 @@ class DefaultVoiceMessageComposerPresenterTest { @Test fun `present - record error - security exceptions are tracked`() = runTest { val exception = SecurityException("") - voiceRecorder.givenThrowsSecurityException(exception) - val presenter = createDefaultVoiceMessageComposerPresenter() + val startRecordResult = lambdaRecorder { throw exception } + val voiceRecorder = FakeVoiceRecorder( + recordingDuration = RECORDING_DURATION, + startRecordResult = startRecordResult, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + voiceRecorder = voiceRecorder, + ) presenter.test { val initialState = awaitItem() initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) @@ -596,7 +642,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(analyticsService.trackedErrors).containsExactly( VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) ) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(initialState) } @@ -616,7 +662,8 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) - voiceRecorder.assertCalls(stopped = 1) + startRecordResult.assertions().isNeverCalled() + stopRecordResult.assertions().isCalledOnce().with(value(false)) permissionsPresenter.setPermissionGranted() @@ -625,7 +672,8 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(stopped = 1, started = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) testPauseAndDestroy(finalState) } @@ -659,7 +707,7 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -696,7 +744,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(it.showPermissionRationaleDialog).isTrue() } - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() cancelAndIgnoreRemainingEvents() testPauseAndDestroy(finalState) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index ac1ba06cf6..c5a9b430ee 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -30,6 +30,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.core.data.ByteUnit +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId @@ -50,6 +51,7 @@ class DeveloperSettingsPresenter( private val databaseSizesUseCase: GetDatabaseSizesUseCase, private val fileSizeFormatter: FileSizeFormatter, private val markAllRoomsAsRead: MarkAllRoomsAsRead, + private val buildMeta: BuildMeta, ) : Presenter { @Composable override fun present(): DeveloperSettingsState { @@ -117,7 +119,7 @@ class DeveloperSettingsPresenter( databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, markAllRoomsAsReadAction = markAllRoomsAsReadAction.value, - isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + isEnterpriseBuild = buildMeta.isEnterpriseBuild, showColorPicker = showColorPicker, deviceId = deviceId, eventSink = ::handleEvent, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index bb688f498d..3b2645783f 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -23,12 +23,14 @@ import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.megaBytes +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -108,10 +110,8 @@ class DeveloperSettingsPresenterTest { fun `present - enterprise build can change the brand color`() = runTest { val overrideBrandColorResult = lambdaRecorder { _, _ -> } val presenter = createDeveloperSettingsPresenter( - enterpriseService = FakeEnterpriseService( - isEnterpriseBuild = true, - overrideBrandColorResult = overrideBrandColorResult, - ) + enterpriseService = FakeEnterpriseService(overrideBrandColorResult = overrideBrandColorResult), + buildMeta = aBuildMeta(isEnterpriseBuild = true), ) presenter.test { skipItems(1) @@ -179,6 +179,7 @@ class DeveloperSettingsPresenterTest { vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), + buildMeta: BuildMeta = aBuildMeta(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() }, @@ -191,6 +192,7 @@ class DeveloperSettingsPresenterTest { databaseSizesUseCase = databaseSizesUseCase, fileSizeFormatter = FakeFileSizeFormatter(), markAllRoomsAsRead = markAllRoomsAsRead, + buildMeta = buildMeta, ) } } diff --git a/features/preferences/test/build.gradle.kts b/features/preferences/test/build.gradle.kts index a066fe4707..5b8969796a 100644 --- a/features/preferences/test/build.gradle.kts +++ b/features/preferences/test/build.gradle.kts @@ -16,5 +16,7 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.features.preferences.api) + implementation(projects.libraries.matrix.api) + implementation(libs.coroutines.core) implementation(projects.tests.testutils) } diff --git a/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt new file mode 100644 index 0000000000..9696a2050b --- /dev/null +++ b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.test + +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class FakeCacheService( + override val clearedCacheEventFlow: Flow = emptyFlow(), +) : CacheService diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt index 85731ae959..c7c706fa68 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt @@ -86,7 +86,7 @@ class BugReportPresenter( fun handleEvent(event: BugReportEvents) { when (event) { BugReportEvents.SendBugReport -> { - if (formState.value.description.length < 10) { + if (formState.value.description.length < 10 && formState.value.ghIssueNumber == null) { sendingAction.value = AsyncAction.Failure(BugReportFormError.DescriptionTooShort) } else { sendingAction.value = AsyncAction.Loading diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts index afc5e51499..faad812d73 100644 --- a/features/rageshake/test/build.gradle.kts +++ b/features/rageshake/test/build.gradle.kts @@ -14,6 +14,7 @@ android { } dependencies { + implementation(projects.libraries.architecture) implementation(projects.features.rageshake.api) implementation(libs.coroutines.core) implementation(projects.tests.testutils) diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt new file mode 100644 index 0000000000..d6e1a47368 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeBugReportEntryPoint( + private val createNodeResult: (BuildContext, BugReportEntryPoint.Callback) -> Node = { _, _ -> lambdaError() }, +) : BugReportEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: BugReportEntryPoint.Callback, + ): Node = createNodeResult(buildContext, callback) +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt index 78be168e53..1ec0ce0691 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt @@ -24,7 +24,10 @@ import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeoutException import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration.Companion.seconds @AssistedInject class RoomAliasResolverPresenter( @@ -61,9 +64,14 @@ class RoomAliasResolverPresenter( private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { suspend { - matrixClient.resolveRoomAlias(roomAlias) + val result = withTimeoutOrNull(ALIAS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) { + matrixClient.resolveRoomAlias(roomAlias) + } ?: throw TimeoutException("Timed out while resolving ${roomAlias.value}") + result .getOrThrow() .getOrElse { throw RoomAliasResolverFailures.UnknownAlias } }.runCatchingUpdatingState(resolveState) } } + +private const val ALIAS_RESOLVE_TIMEOUT_IN_SECONDS = 10 diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt index df8b8058be..a50049446b 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt @@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SERVER_LIST import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import java.util.Optional +import java.util.concurrent.TimeoutException class RoomAliasHelperPresenterTest { @get:Rule @@ -60,6 +62,22 @@ class RoomAliasHelperPresenterTest { } } + @Test + fun `present - a server that never answers ends up in an error state`() = runTest { + val client = FakeMatrixClient( + resolveRoomAliasResult = { awaitCancellation() } + ) + val presenter = createPresenter(matrixClient = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().resolveState.isUninitialized()).isTrue() + assertThat(awaitItem().resolveState.isLoading()).isTrue() + val resultState = awaitItem() + assertThat(resultState.resolveState.errorOrNull()).isInstanceOf(TimeoutException::class.java) + } + } + @Test fun `present - resolve alias error and retry`() = runTest { val client = FakeMatrixClient( diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt index cfa06c3a97..2ae31fb00c 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt @@ -42,7 +42,8 @@ class DefaultShareIntentHandler( val uris = getIncomingUris(intent, type) return when { uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent) - type.isMimeTypeImage() || + uris.isNotEmpty() || + type.isMimeTypeImage() || type.isMimeTypeVideo() || type.isMimeTypeAudio() || type.isMimeTypeApplication() || diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt new file mode 100644 index 0000000000..d81bbe053d --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandlerTest.kt @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.ComponentName +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import android.database.Cursor +import android.net.Uri +import androidx.core.net.toUri +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.Robolectric +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +class DefaultShareIntentHandlerTest : RobolectricTest() { + @Test + fun `an image is shared as an uri`() { + val uri = "content://sender/image.jpg".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "image/jpeg", uri = uri) + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "image/jpeg")), + ) + ) + } + + @Test + fun `an uri with an unlisted mime type is shared`() { + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "message/rfc822", uri = uri) + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + @Test + fun `an uri with an unlisted mime type keeps the text as a caption`() { + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "message/rfc822", uri = uri).apply { + putExtra(Intent.EXTRA_TEXT, "a caption") + } + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = "a caption", + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + @Test + fun `several uris with an unlisted mime type are shared`() { + val firstUri = "content://sender/first.zip".toUri() + val secondUri = "content://sender/second.zip".toUri() + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "message/rfc822" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(firstUri, secondUri)) + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf( + UriToShare(uri = firstUri, mimeType = "message/rfc822"), + UriToShare(uri = secondUri, mimeType = "message/rfc822"), + ), + ) + ) + } + + @Test + fun `a plain text is shared as a text`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "a text") + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo(ShareIntentData.PlainText("a text")) + } + + @Test + fun `an intent with neither uri nor text is not handled`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isNull() + } + + @Test + fun `an intent without mime type is not handled`() { + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(Intent(Intent.ACTION_SEND)) + assertThat(result).isNull() + } + + @Test + fun `an intent with a listed mime type and no uri is handled`() { + listOf( + MimeTypes.Jpeg, + MimeTypes.Mp4, + MimeTypes.Mp3, + MimeTypes.Pdf, + "file/binary", + "text/html", + MimeTypes.Any, + ).forEach { type -> + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + Intent(Intent.ACTION_SEND).apply { this.type = type } + ) + assertWithMessage("$type was not properly handled").that(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = emptyList(), + ) + ) + } + } + + @Test + fun `a send multiple intent without uri is handled`() { + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = MimeTypes.Jpeg + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = emptyList(), + ) + ) + } + + @Test + fun `an uri is ignored when the intent is neither a send nor a send multiple`() { + val intent = Intent(Intent.ACTION_VIEW).apply { + type = MimeTypes.Jpeg + putExtra(Intent.EXTRA_STREAM, "content://sender/image.jpg".toUri()) + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = emptyList(), + ) + ) + } + + @Test + fun `an empty text is not kept as a caption`() { + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = "message/rfc822", uri = uri).apply { + putExtra(Intent.EXTRA_TEXT, "") + } + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + @Test + fun `a plain text intent without text is not handled`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = MimeTypes.PlainText + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isNull() + } + + @Test + fun `a plain text intent with an empty text is not handled`() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = MimeTypes.PlainText + putExtra(Intent.EXTRA_TEXT, "") + } + val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(result).isNull() + } + + @Test + fun `the mime type of the uri overrides the one of the intent`() { + Robolectric.setupContentProvider(ZipContentProvider::class.java, "sender") + val uri = "content://sender/logs.zip".toUri() + val result = createDefaultShareIntentHandler().handleIncomingShareIntent( + aSendIntent(type = MimeTypes.Any, uri = uri) + ) + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "application/zip")), + ) + ) + } + + @Test + fun `the intent is made explicit for the application which will read the uri`() { + val intent = aSendIntent(type = "message/rfc822", uri = "content://sender/logs.zip".toUri()) + givenAnApplicationHandling(intent) + createDefaultShareIntentHandler().handleIncomingShareIntent(intent) + assertThat(intent.action).isNull() + assertThat(intent.component).isEqualTo(ComponentName(A_PACKAGE_NAME, AN_ACTIVITY_NAME)) + } + + @Test + fun `the intent stays implicit when the uri permission cannot be granted`() { + val uri = "content://sender/logs.zip".toUri() + val intent = aSendIntent(type = "message/rfc822", uri = uri) + givenAnApplicationHandling(intent) + val result = DefaultShareIntentHandler( + context = FailingGrantUriPermissionContext(RuntimeEnvironment.getApplication()), + ).handleIncomingShareIntent(intent) + assertThat(intent.action).isEqualTo(Intent.ACTION_SEND) + assertThat(intent.component).isNull() + assertThat(result).isEqualTo( + ShareIntentData.Uris( + text = null, + uris = listOf(UriToShare(uri = uri, mimeType = "message/rfc822")), + ) + ) + } + + private fun aSendIntent(type: String, uri: Uri) = Intent(Intent.ACTION_SEND).apply { + this.type = type + putExtra(Intent.EXTRA_STREAM, uri) + } + + private fun givenAnApplicationHandling(intent: Intent) { + val componentName = ComponentName(A_PACKAGE_NAME, AN_ACTIVITY_NAME) + shadowOf(RuntimeEnvironment.getApplication().packageManager).apply { + addActivityIfNotPresent(componentName) + addIntentFilterForActivity( + componentName, + IntentFilter(intent.action).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addDataType(intent.type) + } + ) + } + } + + private fun createDefaultShareIntentHandler() = DefaultShareIntentHandler( + context = RuntimeEnvironment.getApplication(), + ) + + private class FailingGrantUriPermissionContext(context: Context) : ContextWrapper(context) { + override fun grantUriPermission(toPackage: String, uri: Uri, modeFlags: Int) { + error("Unable to grant Uri permission") + } + } + + private class ZipContentProvider : ContentProvider() { + override fun onCreate() = true + + override fun getType(uri: Uri) = "application/zip" + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + } + + private companion object { + const val A_PACKAGE_NAME = "io.element.android.x" + const val AN_ACTIVITY_NAME = "io.element.android.x.MainActivity" + } +} diff --git a/features/signedout/test/build.gradle.kts b/features/signedout/test/build.gradle.kts new file mode 100644 index 0000000000..5bbfb33c1a --- /dev/null +++ b/features/signedout/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.signedout.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.features.signedout.api) + implementation(projects.tests.testutils) +} diff --git a/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt new file mode 100644 index 0000000000..863b946871 --- /dev/null +++ b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSignedOutEntryPoint( + private val createNodeResult: (BuildContext, SignedOutEntryPoint.Params) -> Node = { _, _ -> lambdaError() }, +) : SignedOutEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SignedOutEntryPoint.Params, + ): Node = createNodeResult(buildContext, params) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt index 465fc20066..c97bf2cd2e 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt @@ -15,7 +15,6 @@ import com.freeletics.flowredux.dsl.FlowReduxStateMachine import io.element.android.features.verifysession.impl.util.andLogStateChange import io.element.android.features.verifysession.impl.util.logReceivedEvents import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.verification.SessionVerificationData @@ -23,6 +22,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.VerificationRequest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.timeout @@ -95,10 +95,13 @@ class OutgoingVerificationStateMachine( // If a key backup exists, wait until it's restored or a timeout happens val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse() if (hasBackup) { - tryOrNull { - encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED } + try { + encryptionService.recoveryStateStateFlow + .filter { it == RecoveryState.ENABLED || it == RecoveryState.DISABLED || it == RecoveryState.INCOMPLETE } .timeout(10.seconds) .first() + } catch (_: TimeoutCancellationException) { + // The backup was not restored in time, proceed to the Completed state anyway } } state.override { State.Completed.andLogStateChange() } diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt index c6cbfca281..577683d12e 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus @@ -216,6 +217,85 @@ class OutgoingVerificationPresenterTest { } } + @Test + fun `present - When verification is approved and a backup exists but recovery is disabled, the flow completes`() = runTest { + val service = unverifiedSessionService( + requestUserVerificationLambda = { }, + startSasVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val encryptionService = FakeEncryptionService().apply { + givenDoesBackupExistOnServerResult(Result.success(true)) + recoveryStateStateFlow.value = RecoveryState.DISABLED + } + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + encryptionService = encryptionService, + ) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + assertThat(awaitItem().step).isEqualTo(Step.Completed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved and a backup exists but recovery is incomplete, the flow completes`() = runTest { + val service = unverifiedSessionService( + requestUserVerificationLambda = { }, + startSasVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val encryptionService = FakeEncryptionService().apply { + givenDoesBackupExistOnServerResult(Result.success(true)) + recoveryStateStateFlow.value = RecoveryState.INCOMPLETE + } + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + encryptionService = encryptionService, + ) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + assertThat(awaitItem().step).isEqualTo(Step.Completed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved but the recovery state is not determined, the flow completes after the timeout`() = runTest { + val service = unverifiedSessionService( + requestUserVerificationLambda = { }, + startSasVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val encryptionService = FakeEncryptionService().apply { + givenDoesBackupExistOnServerResult(Result.success(true)) + } + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + encryptionService = encryptionService, + ) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + // The recovery state never leaves UNKNOWN, so the 10 seconds timeout fires and the flow completes anyway + this@runTest.advanceUntilIdle() + assertThat(awaitItem().step).isEqualTo(Step.Completed) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - When verification is declined, the flow is canceled`() = runTest { val service = unverifiedSessionService( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c834fd291..95242ad038 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,12 +17,12 @@ constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" -media3 = "1.10.1" +media3 = "1.11.0" camera = "1.6.1" work = "2.11.2" # Compose -compose_bom = "2026.06.01" +compose_bom = "2026.08.00" # Coroutines coroutines = "1.11.0" @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.71.0" +roborazzi = "1.72.0" # Jetbrain datetime = "0.8.0" @@ -54,7 +54,7 @@ haze = "1.7.2" dependencyAnalysis = "3.18.0" # DI -metro = "1.4.1" +metro = "1.4.2" # Auto service autoservice = "1.1.1" @@ -123,7 +123,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = "androidx.startup:startup-runtime:1.2.0" androidx_preference = "androidx.preference:preference:1.2.1" -androidx_webkit = "androidx.webkit:webkit:1.16.0" +androidx_webkit = "androidx.webkit:webkit:1.17.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' } @@ -149,7 +149,7 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi squareup_seismic = "com.squareup:seismic:1.0.3" # network -network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.4.0" +network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.5.0" network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" } network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" } network_okhttp = { module = "com.squareup.okhttp3:okhttp" } @@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.13" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -215,7 +215,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:13.4.1" +maplibre = "org.maplibre.gl:android-sdk:13.5.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_compose = "org.maplibre.compose:maplibre-compose:0.14.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" @@ -230,7 +230,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.56.4" -sentry = "io.sentry:sentry-android:8.52.0" +sentry = "io.sentry:sentry-android:8.53.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" @@ -242,7 +242,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.23.0" +element_call_embedded = "io.element.android:element-call-embedded:0.24.0" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 84c5a9c661..760c45969c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/libraries/accountselect/test/build.gradle.kts b/libraries/accountselect/test/build.gradle.kts new file mode 100644 index 0000000000..c38cd8ac69 --- /dev/null +++ b/libraries/accountselect/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.accountselect.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt new file mode 100644 index 0000000000..acc5ba10df --- /dev/null +++ b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountSelectEntryPoint( + private val createNodeResult: (BuildContext, AccountSelectEntryPoint.Callback) -> Node = { _, _ -> lambdaError() }, +) : AccountSelectEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: AccountSelectEntryPoint.Callback, + ): Node = createNodeResult(buildContext, callback) +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt new file mode 100644 index 0000000000..b17ec0260b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.media + +import android.app.PictureInPictureParams +import android.content.res.Configuration +import android.os.Build +import android.util.Rational +import androidx.annotation.RequiresApi + +/** + * Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device. + */ +@RequiresApi(Build.VERSION_CODES.O) +fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder { + val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + // If landscape orientation, invert the aspect ratio + Rational(5, 3) + } else { + // In any other case, assume portrait orientation + Rational(3, 5) + } + return setAspectRatio(aspectRatio) +} diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts index 419e81962c..c0db3c4f86 100644 --- a/libraries/audio/impl/build.gradle.kts +++ b/libraries/audio/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -22,4 +23,6 @@ dependencies { implementation(libs.androidx.corektx) implementation(projects.libraries.di) + + testCommonDependencies(libs) } diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt index bc6a42d2f9..a9bc6fdbfd 100644 --- a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt +++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt @@ -48,9 +48,8 @@ class DefaultAudioFocus( } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - // For recording, ignore transient focus losses (e.g., notification sounds). - // The AudioRecord API keeps capturing regardless. - if (requester != AudioFocusRequester.RecordVoiceMessage) { + Timber.d("AudioFocus: transient loss ($it)") + if (requester.pausesOnTransientFocusLoss()) { onFocusLost() } } @@ -96,8 +95,8 @@ class DefaultAudioFocus( private fun AudioFocusRequester.toAudioUsage(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage, AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioFocusRequester.VoiceMessage, AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA } } @@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int { private fun AudioFocusRequester.toAudioStream(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage, AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL + AudioFocusRequester.VoiceMessage, AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC } } +private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean { + return when (this) { + // The AudioRecord API keeps capturing regardless. + AudioFocusRequester.RecordVoiceMessage, + AudioFocusRequester.VoiceMessage -> false + AudioFocusRequester.ElementCall, + AudioFocusRequester.MediaViewer -> true + } +} + private fun AudioFocusRequester.willPausedWhenDucked(): Boolean { return when (this) { // (note that for Element Call, there is no action when the focus is lost) diff --git a/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt new file mode 100644 index 0000000000..d0e255dd41 --- /dev/null +++ b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * Copyright 2026 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.audio.impl + +import android.media.AudioAttributes +import android.media.AudioManager +import androidx.core.content.getSystemService +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +class DefaultAudioFocusTest : RobolectricTest() { + @Test + fun `voice message playback requests focus for media, not for a call`() { + assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA) + } + + @Test + fun `media viewer playback requests focus for media`() { + assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA) + } + + @Test + fun `voice message recording requests focus for voice communication`() { + assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION) + } + + @Test + fun `element call requests focus for voice communication`() { + assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION) + } + + @Test + fun `a transient focus loss does not pause a voice message`() { + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse() + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse() + } + + @Test + fun `a permanent focus loss pauses a voice message`() { + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue() + } + + @Test + fun `a transient focus loss does not stop a voice message recording`() { + assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse() + } + + @Test + fun `a transient focus loss still pauses the media viewer`() { + assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue() + } + + private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean { + val context = RuntimeEnvironment.getApplication() + val audioManager = requireNotNull(context.getSystemService()) + var focusLost = false + DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true } + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + request.listener.onAudioFocusChange(focusChange) + return focusLost + } + + private fun usageOf(requester: AudioFocusRequester): Int { + val context = RuntimeEnvironment.getApplication() + val audioManager = requireNotNull(context.getSystemService()) + DefaultAudioFocus(context).requestAudioFocus(requester) {} + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + return requireNotNull(request.audioFocusRequest).audioAttributes.usage + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt index bf5932da59..e519984e9f 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt @@ -8,8 +8,11 @@ package io.element.android.compound.theme -import androidx.compose.foundation.isSystemInDarkTheme +import android.content.res.Configuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -27,12 +30,28 @@ private fun Theme.coerceBlackTheme(allowBlackTheme: Boolean): Theme { @Composable fun Theme.isDark(): Boolean { return when (this) { - Theme.System -> isSystemInDarkTheme() + Theme.System -> isSystemThemeDark() Theme.Dark, Theme.Black -> true Theme.Light -> false } } +/** + * Whether the system-wide theme is dark. + * + * This deliberately does not use [androidx.compose.foundation.isSystemInDarkTheme], which reads the activity configuration: `AppCompatDelegate` rewrites + * that configuration whenever a non-system theme is selected, so it keeps reporting the previously forced value. The application configuration is left + * untouched by AppCompat and is what AppCompat itself reads to expand `MODE_NIGHT_FOLLOW_SYSTEM`. + */ +@Composable +private fun isSystemThemeDark(): Boolean { + val applicationContext = LocalContext.current.applicationContext + val activityConfiguration = LocalConfiguration.current + return remember(activityConfiguration) { + applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + } +} + fun Flow.mapToTheme(allowBlackTheme: Boolean): Flow = map { when (it) { null -> Theme.System diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt index 3f43cfea67..57ae9dd332 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt @@ -11,19 +11,22 @@ package io.element.android.compound.theme import android.content.res.Configuration import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test +import org.robolectric.RuntimeEnvironment -class ThemeTest { +class ThemeTest : RobolectricTest() { @Test fun `isDark for System dark returns true`() { `isDark for System`( - uiMode = Configuration.UI_MODE_NIGHT_YES, + systemUiMode = Configuration.UI_MODE_NIGHT_YES, expected = true, ) } @@ -31,21 +34,38 @@ class ThemeTest { @Test fun `isDark for System light return false`() { `isDark for System`( - uiMode = Configuration.UI_MODE_NIGHT_NO, + systemUiMode = Configuration.UI_MODE_NIGHT_NO, expected = false, ) } - fun `isDark for System`( - uiMode: Int, + @Test + fun `isDark for System ignores the night mode AppCompat forced on the activity`() { + `isDark for System`( + systemUiMode = Configuration.UI_MODE_NIGHT_YES, + activityUiMode = Configuration.UI_MODE_NIGHT_NO, + expected = true, + ) + `isDark for System`( + systemUiMode = Configuration.UI_MODE_NIGHT_NO, + activityUiMode = Configuration.UI_MODE_NIGHT_YES, + expected = false, + ) + } + + private fun `isDark for System`( + systemUiMode: Int, + activityUiMode: Int = systemUiMode, expected: Boolean, ) = runTest { + val application = RuntimeEnvironment.getApplication() + application.resources.configuration.uiMode = systemUiMode moleculeFlow(RecompositionMode.Immediate) { var result: Boolean? = null CompositionLocalProvider( - // Let set the system to dark + LocalContext provides application, LocalConfiguration provides Configuration().apply { - this.uiMode = uiMode + this.uiMode = activityUiMode }, ) { result = Theme.System.isDark() diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index fe563138ad..87d6350c65 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.core.extensions +import java.text.Normalizer import java.util.Locale fun Boolean.toOnOff() = if (this) "ON" else "OFF" @@ -114,3 +115,14 @@ fun String.toSafeLength( this } } + +/** + * Remove accents and replace special characters by ASCII from the string. + * For instance, "é" will be replaced by "e", "ç" by "c", etc. + */ +fun String.normalized(): String { + // Normalize the string. + return Normalizer.normalize(this, Normalizer.Form.NFD) + // then keep only ASCII characters and replace non-ASCII characters with an empty string. + .replace("[^\\p{ASCII}]".toRegex(), "") +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index e7962f8bd6..3901a32059 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -63,4 +63,17 @@ object MimeTypes { val subType = components.last() return subType.isNotBlank() && subType != "*" } + + /** + * A mime type with a wildcard subtype is a filter, not a media type, so replace it with a + * concrete default for its top level type. + */ + fun String?.ensureDefaultSubtype(): String = when { + this == null -> OctetStream + hasSubtype(this) -> this + this == Images -> Jpeg + this == Videos -> Mp4 + this == Audio -> Mp3 + else -> OctetStream + } } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt index 666ee1d7ab..91402e3d11 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt @@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean { } /** - * Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty + * Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is + * not the case, "https://" is added, only if the String is not empty. */ fun String.ensureProtocol(): String { return when { isEmpty() -> this - !startsWith("http") -> "https://$this" + !startsWith("http", ignoreCase = true) -> "https://$this" else -> this } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt index 0099a67a79..c3cae14843 100644 --- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt @@ -74,4 +74,10 @@ class BasicExtensionsTest { val result = textWithDirectionOverrides.filterDirectionOverrides() assertEquals("123456789", result) } + + @Test + fun `normalized removes accents and replace special characters from text`() { + assertEquals("Stefan", "Ștefan".normalized()) + assertEquals("ca enleve etre", "ça enlève être".normalized()) + } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt new file mode 100644 index 0000000000..f12b73b40b --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/mimetype/MimeTypesTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.mimetype + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype +import org.junit.Test + +class MimeTypesTest { + @Test + fun `ensureDefaultSubtype keeps a mime type that already has a subtype`() { + assertThat(MimeTypes.Png.ensureDefaultSubtype()).isEqualTo(MimeTypes.Png) + assertThat(MimeTypes.Mp4.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4) + assertThat(MimeTypes.Pdf.ensureDefaultSubtype()).isEqualTo(MimeTypes.Pdf) + } + + @Test + fun `ensureDefaultSubtype replaces a wildcard subtype with a concrete one`() { + assertThat(MimeTypes.Images.ensureDefaultSubtype()).isEqualTo(MimeTypes.Jpeg) + assertThat(MimeTypes.Videos.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4) + assertThat(MimeTypes.Audio.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp3) + } + + @Test + fun `ensureDefaultSubtype falls back to octet stream`() { + assertThat(MimeTypes.Any.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + assertThat("text/*".ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + assertThat(null.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream) + } +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt new file mode 100644 index 0000000000..9a9d68dcaa --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.uri + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UrlUtilsTest { + @Test + fun `ensureProtocol keeps an empty string unchanged`() { + assertThat("".ensureProtocol()).isEqualTo("") + } + + @Test + fun `ensureProtocol prepends https when there is no scheme`() { + assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org") + } + + @Test + fun `ensureProtocol keeps an existing http or https scheme`() { + assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org") + assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org") + } + + @Test + fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() { + assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org") + assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org") + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index ac7e426e65..99c45c67d9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.designsystem.components.avatar +import androidx.compose.runtime.Immutable import io.element.android.libraries.core.data.tryOrNull import java.text.BreakIterator +@Immutable data class AvatarData( val id: String, val name: String?, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index eb27780073..b91f8c405c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.designsystem.components.avatar +import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +@Immutable enum class AvatarSize(val dp: Dp) { CurrentUserTopBar(32.dp), CurrentRoomTopBar(32.dp), @@ -18,6 +20,7 @@ enum class AvatarSize(val dp: Dp) { IncomingCall(140.dp), RoomDetailsHeader(96.dp), RoomListItem(52.dp), + SearchRoomListItem(32.dp), ThreadsListItem(52.dp), SpaceListItem(52.dp), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt similarity index 63% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt index a6fb234b86..611bf7bf18 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt @@ -8,9 +8,19 @@ package io.element.android.libraries.matrix.api /** - * An interface to fetch content from a URL using a simple GET request. + * An interface to fetch content from a URL using a simple GET request launched from a Matrix client. */ -fun interface UrlContentFetcher { +interface ClientUrlContentFetcher { + /** + * The domain of the public server for this homeserver. + */ + val server: String? + + /** + * The URL of the actual Matrix homeserver. + */ + val homeserverUrl: String + /** * Execute generic GET requests through the SDKs internal HTTP client. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index b83d96af96..3f98dcc509 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -60,16 +60,13 @@ import java.util.Optional * One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation. * Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread. */ -interface MatrixClient : UrlContentFetcher { +interface MatrixClient : ClientUrlContentFetcher { /** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */ val sessionId: SessionId /** Identifier of this device, as assigned by the homeserver when the session was created. */ val deviceId: DeviceId - /** Base URL of the homeserver this client is connected to. */ - val homeserverUrl: String - /** Directories holding this session's databases, caches and downloaded media. */ val sessionPaths: SessionPaths @@ -136,6 +133,9 @@ interface MatrixClient : UrlContentFetcher { /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */ + val isShuttingDown: Boolean + /** * Returns the room with the given id if the user has joined it, along with its live [Timeline]. * A new instance is created on each call and the caller owns it, so it must be closed once no longer needed. @@ -245,6 +245,9 @@ interface MatrixClient : UrlContentFetcher { /** Whether the homeserver advertises support for user status (MSC4426). */ suspend fun isUserStatusSupported(): Result + /** Whether the homeserver advertises support for the Profiles sliding sync extension (MSC4262). */ + suspend fun isProfilesSlidingSyncExtensionSupported(): Result + /** * Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. * diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt new file mode 100644 index 0000000000..730561bbef --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientBuilder.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +/** + * An abstraction over the Matrix SDK's `ClientBuilder` to allow sharing it with other modules without exposing the SDK directly. + */ +interface MatrixClientBuilder diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt index 1e3ffea154..5013362f85 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt @@ -15,4 +15,4 @@ import java.lang.AutoCloseable * Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated * with it, such as temporary files. */ -interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable +interface TemporaryMatrixClient : ClientUrlContentFetcher, AutoCloseable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt index 3bd22c64f3..c3244b4742 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt @@ -12,10 +12,10 @@ package io.element.android.libraries.matrix.api */ interface TemporaryMatrixClientFactory { /** - * Builds an unauthenticated client pointing at [homeServerUrl], backed by a fresh set of temporary session directories. + * Builds an unauthenticated client pointing at [serverName], backed by a fresh set of temporary session directories. * The caller owns the returned client and must close it to delete those directories. * - * @param homeServerUrl the URL of the homeserver the client will talk to. + * @param serverName the domain of the public-facing server for the actual homeserver */ - suspend fun create(homeServerUrl: String): Result + suspend fun create(serverName: String): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt deleted file mode 100644 index 2c11c750e4..0000000000 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.matrix.api.scanner - -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId - -/** - * Provides the URL of the content scanner service for a given homeserver, if any is set up. - */ -fun interface ContentScannerUrlProvider { - /** - * Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up. - */ - suspend fun getContentScannerUrl(sessionId: SessionId): Result - - /** - * Creates a provider, which needs a fetcher because the scanner URL is itself discovered over HTTP. - */ - fun interface Factory { - /** - * @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works. - */ - fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider - } -} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index 2f61b8f58a..f2bd56ac57 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -128,4 +128,11 @@ sealed interface GalleryItemType { data class OtherMessageType( val msgType: String, val body: String, -) : MessageType +) : MessageType { + /** + * A `m.key.verification.request` carries a body meant for clients that cannot handle in-chat + * verification, so it must not be rendered as a message. + */ + val isKeyVerificationRequest: Boolean + get() = msgType == "m.key.verification.request" +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 0ff563a204..a36184d6bd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File import java.util.Optional +import java.util.concurrent.atomic.AtomicBoolean import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -145,7 +146,7 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService @Suppress("LargeClass") class RustMatrixClient( override val sessionPaths: SessionPaths, - private val innerClient: Client, + val innerClient: Client, private val sessionStore: SessionStore, private val sessionDelegate: RustClientSessionDelegate, private val innerSyncService: ClientSyncService, @@ -162,6 +163,7 @@ class RustMatrixClient( ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) + override val server: String? = innerClient.server() override val homeserverUrl: String = innerClient.homeserver() override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId") private val sessionDispatcher = dispatchers.io.limitedParallelism(64) @@ -304,6 +306,10 @@ class RustMatrixClient( .buffer(Channel.UNLIMITED) .stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf()) + private val _isShuttingDown = AtomicBoolean(false) + override val isShuttingDown: Boolean + get() = _isShuttingDown.get() + init { // Make sure the session delegate has a reference to the client to be able to logout on auth error sessionDelegate.bindClient(this) @@ -320,7 +326,9 @@ class RustMatrixClient( } private suspend fun setupUserProfile() { - val supported = isUserStatusSupported().getOrDefault(false) + // Subscribing to own profile updates only requires the Profiles sliding sync extension, + // not the full user status capability (which also needs the status profile field to be settable). + val supported = isProfilesSlidingSyncExtensionSupported().getOrDefault(false) if (supported) { // No need to seed the data here, it's already stored by the sdk. ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener) @@ -530,6 +538,12 @@ class RustMatrixClient( } } + override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isProfilesSlidingSyncExtensionSupported() + } + } + override fun enableAutomaticCallStatus(enabled: Boolean) { innerClient.enableAutomaticCallStatus(enabled) } @@ -666,11 +680,13 @@ class RustMatrixClient( } override suspend fun clearCache() { + _isShuttingDown.set(true) innerClient.clearCaches(innerSyncService) destroy() } override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) { + _isShuttingDown.set(true) sessionCoroutineScope.cancel() // Remove current delegate so we don't receive an auth error clientDelegateTaskHandle?.cancelAndDestroy() @@ -708,6 +724,8 @@ class RustMatrixClient( } override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = withContext(sessionDispatcher) { + _isShuttingDown.set(true) + Timber.w("Deactivating account") // Remove current delegate so we don't receive an auth error clientDelegateTaskHandle?.cancelAndDestroy() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt new file mode 100644 index 0000000000..b2f952f35b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientBuilder.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import org.matrix.rustcomponents.sdk.ClientBuilder + +/** + * A wrapper around the Matrix SDK's [ClientBuilder] to allow sharing it with other modules without exposing the SDK directly. + */ +class RustMatrixClientBuilder( + val inner: ClientBuilder, +) : MatrixClientBuilder diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index a0b06a2e1e..0d593626bb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl import dev.zacsweers.metro.Inject +import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook import io.element.android.libraries.androidutils.crypto.ClientSecret import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.ByteUnit @@ -19,7 +20,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.paths.SessionPaths -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.proxy.ProxyProvider @@ -37,7 +37,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder -import org.matrix.rustcomponents.sdk.ContentScanner import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.Session @@ -70,7 +69,7 @@ class RustMatrixClientFactory( private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, private val workManagerScheduler: WorkManagerScheduler, - private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory, + private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook, ) { private val sessionDelegate = RustClientSessionDelegate( sessionStore = sessionStore, @@ -101,6 +100,7 @@ class RustMatrixClientFactory( ) .homeserverUrl(sessionData.homeserverUrl) .username(sessionData.userId) + .let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } .use { it.build() } client.setMediaRetentionPolicy( @@ -135,21 +135,6 @@ class RustMatrixClientFactory( client.setUtdDelegate(UtdTracker(analyticsService)) - // If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client. - // This allows the SDK to use the content scanner for automatic media scanning. - // If no content scanner URL is available, the contentScanner will be null. - val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null)) - val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId())) - .getOrNull() - ?.let { contentScannerUrl -> - val contentScanner = ContentScanner(contentScannerUrl) - client.setContentScanner(contentScanner) - RustContentScanner( - client = client, - rustScanner = contentScanner, - ) - } - val syncService = client.syncService() .withSharePos(true) .withOfflineMode() @@ -170,7 +155,7 @@ class RustMatrixClientFactory( featureFlagService = featureFlagService, analyticsService = analyticsService, workManagerScheduler = workManagerScheduler, - contentScanner = contentScanner, + contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) }, isMessageSearchAvailable = isMessageSearchAvailable, ).also { Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt index 54e4f696d3..f5b1f2ba4e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt @@ -22,6 +22,9 @@ class RustTemporaryMatrixClient( client.getUrl(url) }.mapFailure { it.mapClientException() } + override val server: String? = client.server() + override val homeserverUrl: String = client.homeserver() + override fun close() { client.close() paths?.let { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt index 70903ba91a..0e7d8bf2c5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt @@ -19,7 +19,7 @@ class RustTemporaryMatrixClientFactory( private val sessionPathsFactory: SessionPathsFactory, private val rustMatrixClientFactory: RustMatrixClientFactory, ) : TemporaryMatrixClientFactory { - override suspend fun create(homeServerUrl: String): Result { + override suspend fun create(serverName: String): Result { return runCatchingExceptions { val sessionPaths = sessionPathsFactory.create() val client = rustMatrixClientFactory.getBaseClientBuilder( @@ -28,7 +28,7 @@ class RustTemporaryMatrixClientFactory( slidingSyncType = ClientBuilderSlidingSync.Native, isMessageSearchAvailable = false, ) - .homeserverUrl(homeServerUrl) + .serverName(serverName) .build() RustTemporaryMatrixClient(client, sessionPaths) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index fac5227f6a..6a06ac0599 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -29,6 +29,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException { is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) is ClientBuildException.EventCache -> AuthenticationException.Generic(message) is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message) + is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message) } is OAuthException -> when (this) { is OAuthException.Generic -> AuthenticationException.OAuth(message) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 2c936c9b23..a1dacbee91 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.auth import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.ClientEnterpriseHook import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.androidutils.crypto.ClientSecret import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -73,6 +74,7 @@ class RustMatrixAuthenticationService( private val oAuthConfigurationProvider: OAuthConfigurationProvider, private val enterpriseService: EnterpriseService, private val featureFlagService: FeatureFlagService, + private val clientEnterpriseHook: ClientEnterpriseHook, ) : MatrixAuthenticationService { // Any existing Element Classic session that we want to try to import secrets from during login. private var elementClassicSession: ElementClassicSession? = null @@ -166,6 +168,10 @@ class RustMatrixAuthenticationService( sessionPaths = currentSessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) @@ -242,6 +248,9 @@ class RustMatrixAuthenticationService( client.restoreSession(sessionData.toSession()) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + // We wait for the verification state to be known matrixClient.waitForKnownVerificationState() @@ -278,7 +287,6 @@ class RustMatrixAuthenticationService( .let { enterpriseService.tweakMasUrl( url = it, - homeserver = client.server() ?: client.homeserver(), urlContentFetcher = getUrlResolver, ) } @@ -333,6 +341,10 @@ class RustMatrixAuthenticationService( sessionPaths = currentSessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + matrixClient.waitForKnownVerificationState() newMatrixClientObservers.forEach { it.invoke(matrixClient) } @@ -398,6 +410,10 @@ class RustMatrixAuthenticationService( sessionPaths = emptySessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) + + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 46fa885d25..f6ea8019e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -13,9 +13,9 @@ import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.UrlContentFetcher import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.verification.SessionVerificationService @@ -111,7 +112,12 @@ object SessionMatrixModule { } @Provides - fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher { + fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher { return matrixClient } + + @Provides + fun providesMessageSearchService(matrixClient: MatrixClient): MessageSearchService { + return matrixClient.messageSearchService + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index d1401d2624..7b3a1d9f10 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.media import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource @@ -71,15 +71,8 @@ class RustMediaLoader( val mediaFile = innerClient.getMediaFile( mediaSource = mediaSource, filename = filename, - mimeType = when { - mimeType == null -> MimeTypes.OctetStream - MimeTypes.hasSubtype(mimeType) -> mimeType - // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension. - mimeType == MimeTypes.Images -> MimeTypes.Jpeg - mimeType == MimeTypes.Videos -> MimeTypes.Mp4 - mimeType == MimeTypes.Audio -> MimeTypes.Mp3 - else -> MimeTypes.OctetStream - }, + // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension. + mimeType = mimeType.ensureDefaultSubtype(), useCache = useCache, tempDir = cacheDirectory.path, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt index e59530e874..a5f372d7d1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt @@ -8,11 +8,13 @@ package io.element.android.libraries.matrix.impl.permalink +import android.net.Uri import androidx.core.net.toUri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.MatrixPatterns 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 @@ -51,6 +53,10 @@ class DefaultPermalinkParser( matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) } + matrixToUri.userIdWithSlash()?.let { userId -> + return PermalinkData.UserLink(userId = userId) + } + val result = runCatchingExceptions { parseMatrixEntityFrom(matrixToUri.toString()) }.getOrNull() @@ -84,3 +90,17 @@ class DefaultPermalinkParser( } } } + +/** + * A slash is allowed in the local part of a user id, but it also separates an identifier from an + * event id in a permalink, so [parseMatrixEntityFrom] cannot tell the two apart. Only a user id can + * start with an `@`, so recover it here rather than letting the ambiguity reach the SDK. + */ +private fun Uri.userIdWithSlash(): UserId? { + val identifier = fragment + ?.removePrefix("/") + ?.substringBefore('?') + ?: return null + if (!identifier.startsWith("@") || !identifier.contains('/')) return null + return identifier.takeIf { MatrixPatterns.isUserId(it) }?.let(::UserId) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index bd756bc002..c6a737e156 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -75,6 +75,7 @@ import org.matrix.rustcomponents.sdk.PollData import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle import org.matrix.rustcomponents.sdk.UploadParameters import org.matrix.rustcomponents.sdk.UploadSource +import org.matrix.rustcomponents.sdk.createCaptionEdit import org.matrix.rustcomponents.sdk.use import timber.log.Timber import uniffi.matrix_sdk.PaginationStatus @@ -341,7 +342,7 @@ class RustTimeline( formattedCaption: String?, ): Result = withContext(dispatcher) { runCatchingExceptions { - val editedContent = EditedContent.MediaCaption( + val editedContent = createCaptionEdit( caption = caption, formattedCaption = formattedCaption?.let { FormattedBody(body = it, format = MessageFormat.Html) @@ -365,7 +366,7 @@ class RustTimeline( fromNotification: Boolean, msgType: MsgType, ): Result = withContext(dispatcher) { - runCatchingExceptions { + runCatchingExceptions { val msg = MessageEventContent.from( body = body, htmlBody = htmlBody, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 5e5000aedc..6d3afad5c3 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider @@ -52,7 +51,6 @@ fun TestScope.createRustMatrixClientFactory( ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -67,5 +65,5 @@ fun TestScope.createRustMatrixClientFactory( clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, + clientBuilderEnterpriseHook = { builder, _ -> builder }, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index a8843a9ca2..d0b87ef53a 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory @@ -64,7 +63,6 @@ class RustTemporaryMatrixClientFactoryTest { ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -79,6 +77,6 @@ class RustTemporaryMatrixClientFactoryTest { clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, + clientBuilderEnterpriseHook = { builder, _ -> builder }, ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt index 322103042b..23e0cf2302 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt @@ -16,7 +16,6 @@ import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.NoHandle import java.io.File import kotlin.io.path.createTempDirectory import org.matrix.rustcomponents.sdk.ClientException as RustClientException @@ -84,7 +83,7 @@ class RustTemporaryMatrixClientTest { } private fun createRustTemporaryMatrixClient( - client: Client = Client(NoHandle), + client: Client = FakeFfiClient(), paths: SessionPaths, ): RustTemporaryMatrixClient { return RustTemporaryMatrixClient(client, paths) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index e349c616c0..5d66e4f4bd 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -98,6 +98,7 @@ class RustMatrixAuthenticationServiceTest { ), enterpriseService = enterpriseService, featureFlagService = FakeFeatureFlagService(), + clientEnterpriseHook = {}, ) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 13be3cf2d5..86587da6c1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile +import io.element.android.libraries.matrix.impl.scanner.FakeFfiContentScanner import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_SERVER_NAME import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.ContentScanner import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.HomeserverCapabilities @@ -45,6 +48,7 @@ class FakeFfiClient( private val userId: String = A_USER_ID.value, private val deviceId: String = A_DEVICE_ID.value, private val homeserver: String = A_HOMESERVER_URL, + private val server: String? = A_SERVER_NAME, private val notificationClient: NotificationClient = FakeFfiNotificationClient(), private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(), private val encryption: Encryption = FakeFfiEncryption(), @@ -59,13 +63,16 @@ class FakeFfiClient( private val accountDataResult: (String) -> String? = { lambdaError() }, private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() }, private val isUserStatusSupportedResult: () -> Boolean = { false }, + private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, private val getUrlResult: (String) -> ByteArray = { lambdaError() }, + private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId override fun deviceId(): String = deviceId override fun homeserver(): String = homeserver + override fun server(): String? = server override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings override fun encryption(): Encryption = encryption @@ -100,6 +107,8 @@ class FakeFfiClient( override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult() + override suspend fun isProfilesSlidingSyncExtensionSupported(): Boolean = isProfilesSlidingSyncExtensionSupportedResult() + override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle { subscribeToOwnProfileResult(listener) return FakeFfiTaskHandle() @@ -139,5 +148,9 @@ class FakeFfiClient( getUrlResult(url) } + override suspend fun contentScanner(): ContentScanner = simulateLongTask { + contentScannerResult() + } + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt new file mode 100644 index 0000000000..416213a0b2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.permalink + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class DefaultPermalinkParserTest : RobolectricTest() { + @Test + fun `a matrix-to link to a user id with a slash is parsed as a user link`() { + val result = createParser().parse("https://matrix.to/#/@it/sme:matrix.org") + assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org"))) + } + + @Test + fun `an escaped slash in a user id is parsed as a user link`() { + val result = createParser().parse("https://matrix.to/#/@it%2Fsme:matrix.org") + assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org"))) + } + + @Test + fun `an element web link to a user id with a slash is parsed as a user link`() { + val result = createParser().parse("https://app.element.io/#/user/@it/sme:matrix.org") + assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org"))) + } + + @Test + fun `a user id with a slash that is not a valid user id is not a user link`() { + val result = createParser().parse("https://example.org/foo/@it/sme") + assertThat(result).isInstanceOf(PermalinkData.FallbackLink::class.java) + } + + private fun createParser() = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt new file mode 100644 index 0000000000..2018228985 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeClientUrlContentFetcher( + override val server: String = A_SERVER_NAME, + override val homeserverUrl: String = A_HOMESERVER_URL, + private val getUrlResult: (String) -> Result = { lambdaError() }, +) : ClientUrlContentFetcher { + override suspend fun getUrl(url: String): Result { + return getUrlResult(url) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 56d25ed175..6ba42270fe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -79,6 +79,7 @@ class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")), override val deviceId: DeviceId = A_DEVICE_ID, + override val server: String = A_SERVER_NAME, override val homeserverUrl: String = A_HOMESERVER_URL, override val sessionCoroutineScope: CoroutineScope = TestScope(), private val userDisplayName: String? = A_USER_NAME, @@ -99,7 +100,7 @@ class FakeMatrixClient( override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, - private val resolveRoomAliasResult: (RoomAlias) -> Result> = { + private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) ) @@ -133,6 +134,7 @@ class FakeMatrixClient( private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, override val contentScanner: ContentScanner? = null, + private val isShuttingDownResult: () -> Boolean = { false }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -147,6 +149,7 @@ class FakeMatrixClient( var setUserStatusResult: Result = Result.success(Unit) var clearUserStatusResult: Result = Result.success(Unit) var isUserStatusSupportedResult: Result = Result.success(true) + var isProfilesSlidingSyncExtensionSupportedResult: Result = Result.success(true) private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) override val userProfile: StateFlow = _userProfile @@ -293,6 +296,8 @@ class FakeMatrixClient( override suspend fun isUserStatusSupported(): Result = isUserStatusSupportedResult + override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = isProfilesSlidingSyncExtensionSupportedResult + override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled) override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) @@ -305,6 +310,9 @@ class FakeMatrixClient( return knockRoomLambda(roomIdOrAlias, message, serverNames) } + override val isShuttingDown: Boolean + get() = isShuttingDownResult() + // Mocks fun givenCreateRoomResult(result: Result) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt index 83bde4653a..3ee7fb5f2f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt @@ -11,10 +11,11 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClient import io.element.android.tests.testutils.lambda.lambdaError class FakeTemporaryMatrixClient( + override val server: String = A_SERVER_NAME, + override val homeserverUrl: String = A_HOMESERVER_URL, private val getUrlResult: (String) -> Result = { lambdaError() }, private val closeLambda: () -> Unit = {}, ) : TemporaryMatrixClient { override suspend fun getUrl(url: String): Result = getUrlResult(url) - override fun close() = closeLambda() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt index cdec68bdd1..e906777dcb 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt @@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory class FakeTemporaryMatrixClientFactory( private val createResult: (String) -> Result = { Result.success(FakeTemporaryMatrixClient()) }, ) : TemporaryMatrixClientFactory { - override suspend fun create(homeServerUrl: String): Result = createResult(homeServerUrl) + override suspend fun create(serverName: String): Result = createResult(serverName) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index c41ce0fe3e..c8f49eaf56 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -72,6 +72,7 @@ const val A_SPACE_NAME = "A space name" const val A_REDACTION_REASON = "A redaction reason" +const val A_SERVER_NAME = "matrix.org" const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL_2 = "matrix-client.org" diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index 3b2fdeafb1..1eac6c0a1d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -70,10 +70,15 @@ class FakeMatrixAuthenticationService( return importCreatedSessionLambda(externalSession) } + /** The login hint passed to the most recent [getOAuthUrl] call. */ + var getOAuthUrlLoginHint: String? = null + private set + override suspend fun getOAuthUrl( prompt: OAuthPrompt, loginHint: String?, ): Result = simulateLongTask { + getOAuthUrlLoginHint = loginHint oAuthError?.let { Result.failure(it) } ?: Result.success(AN_OAUTH_DATA) } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index b374e530f4..36c5208603 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo @@ -62,10 +63,10 @@ class AndroidMediaPreProcessor( /** * Used for calculating `inSampleSize` for bitmaps. * - * *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height + * *Note*: Ideally, this should result in images of up to (but not included) 2560x2560 being sent. However, images with very different width and height * values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is). */ - private const val IMAGE_SCALE_REF_SIZE = 640 + private const val IMAGE_SCALE_REF_SIZE = 1280 private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) } @@ -82,15 +83,17 @@ class AndroidMediaPreProcessor( mediaOptimizationConfig: MediaOptimizationConfig, ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { + val resolvedMimeType = mimeType.ensureDefaultSubtype() val result = when { - mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType) - mimeType.isMimeTypeImage() -> { - val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes - processImage(uri, mimeType, shouldBeCompressed) + resolvedMimeType == MimeTypes.Svg -> processSvgImage(uri, resolvedMimeType) + resolvedMimeType.isMimeTypeImage() -> { + val imageMimeType = resolveImageMimeType(uri, mimeType).ensureDefaultSubtype() + val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes + processImage(uri, imageMimeType, shouldBeCompressed) } - mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset) - mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) - else -> processFile(uri, mimeType) + resolvedMimeType.isMimeTypeVideo() -> processVideo(uri, resolvedMimeType, mediaOptimizationConfig.videoCompressionPreset) + resolvedMimeType.isMimeTypeAudio() -> processAudio(uri, resolvedMimeType) + else -> processFile(uri, resolvedMimeType) } if (deleteOriginal) { tryOrNull { @@ -155,6 +158,19 @@ class AndroidMediaPreProcessor( } } + /** + * The declared mime type can be broader than the image actually is - a wildcard from a share intent, or a mislabelled file - and the encoder is picked + * from it. Re-encoding a transparent image as JPEG flattens its alpha channel to black, so resolve the real format from the image header first. + */ + private fun resolveImageMimeType(uri: Uri, declaredMimeType: String): String { + if (declaredMimeType == MimeTypes.Png || declaredMimeType == MimeTypes.Jpeg) return declaredMimeType + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + tryOrNull { + contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } + } + return options.outMimeType ?: declaredMimeType + } + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 3fb639aa9b..090cf11674 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -65,11 +65,26 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { assertThat(height).isEqualTo(2268) } + @Test + fun `test processing an image shared with a wildcard mime type`() = runTest { + val mediaUploadInfo = process( + asset = assetImageJpeg, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg) + } + private suspend fun TestScope.process( asset: Asset, mediaOptimizationConfig: MediaOptimizationConfig, sdkIntVersion: Int = Build.VERSION_CODES.P, deleteOriginal: Boolean = false, + mimeType: String = asset.mimeType, ): MediaUploadInfo { val context = InstrumentationRegistry.getInstrumentation().context val deleteCallback = lambdaRecorder {} @@ -81,7 +96,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { val file = getFileFromAssets(context, asset.filename) val result = sut.process( uri = file.toUri(), - mimeType = asset.mimeType, + mimeType = mimeType, deleteOriginal = deleteOriginal, mediaOptimizationConfig = mediaOptimizationConfig, ) @@ -91,6 +106,52 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { return data } + @Test + fun `a png shared with a wildcard mime type is still encoded as a png`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Png) + } + + @Test + fun `a gif shared with a wildcard mime type is recognised and left uncompressed`() = runTest { + val mediaUploadInfo = process( + asset = assetAnimatedGif, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Gif) + assertThat(info.imageInfo.size).isEqualTo(assetAnimatedGif.size) + } + + @Test + fun `a file that is not an image falls back to the default subtype of the wildcard it was shared with`() = runTest { + val mediaUploadInfo = process( + asset = assetText, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg) + } + @Test @Ignore("Ignore now that min API for enterprise is 33") fun `test processing png`() = runTest { @@ -208,10 +269,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { assertThat(info.thumbnailFile).isNotNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( - height = 979, - width = 3006, + height = 1958, + width = 6012, mimetype = MimeTypes.Jpeg, - size = 84_845, + size = 682_492, ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178), thumbnailSource = null, blurhash = "K07gBzX=j_D4xZjoaSe,s:" @@ -233,10 +294,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { assertThat(info.thumbnailFile).isNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( - height = 979, - width = 3_006, + height = 1_958, + width = 6_012, mimetype = MimeTypes.Jpeg, - size = 84_845, + size = 682_492, thumbnailInfo = null, thumbnailSource = null, blurhash = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt index 4da8433cd1..e23cb790db 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -266,4 +266,5 @@ class ExoPlayerForPreview( override fun setVideoCodecParameters(codecParameters: CodecParameters) {} override fun addVideoCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List) {} override fun removeVideoCodecParametersChangeListener(listener: CodecParametersChangeListener) {} + override fun setEnforceAdPlaybackOnTimelineRefresh(enforceAdPlaybackOnTimelineRefresh: Boolean) = throw NotImplementedError() } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index e53153ce19..77222d97ff 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -92,7 +92,7 @@ class MediaViewerNode( is MediaViewerEntryPoint.Params.RoomMedia -> { val eventId = inputs.eventId if (eventId == null) { - // Should not happen + // Local echo, the viewer will not be focused on the item timelineMediaGalleryDataSource } else { // Can we use a specific timeline? diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt index 3ecd091e0d..2a32c6d273 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt @@ -12,6 +12,7 @@ import android.os.Build import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.extensions.normalized import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.SdkMetadata @@ -26,11 +27,11 @@ class DefaultUserAgentProvider( override fun provide(): String = userAgent /** - * Create an user agent with the application version. + * Create a user agent with the application version. * Ex: Element X/1.5.0 (Xiaomi Mi 9T; Android 11; RKQ1.200826.002; Sdk c344b155c) */ private fun buildUserAgent(): String { - val appName = buildMeta.applicationName + val appName = buildMeta.applicationName.normalized() val appVersion = buildMeta.versionName val deviceManufacturer = Build.MANUFACTURER val deviceModel = Build.MODEL diff --git a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt similarity index 94% rename from libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt rename to libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt index 5a5ca4369e..49abe58622 100644 --- a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt +++ b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.oauth.test.customtab +package io.element.android.libraries.oauth.test import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index f35fa88442..6285c46389 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -84,6 +84,20 @@ interface AppPreferencesStore { /** The configured log level; defaults to the one the build was compiled with. */ fun getTracingLogLevelFlow(): Flow + /** + * The list of account providers (homeserver URLs) the user has previously authenticated + * against, most recent first. Used to default the account provider and to power autocomplete + * suggestions during sign-in. Stored locally only; never synced across devices. + */ + fun getHomeserverHistoryFlow(): Flow> + + /** + * Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]). + * The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are + * moved to the front rather than duplicated, and the list is capped in size. No-op for blanks. + */ + suspend fun addHomeserverToHistory(url: String) + /** * @param targets the extra log packs to enable on top of the default targets. */ diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index af35ac3c44..1a14a0ea8f 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.preferences.impl.store +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") +private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory") private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri") private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion") private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName") @@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion") private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName") +// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String. +private const val HOMESERVER_HISTORY_DELIMITER = "\n" +private const val MAX_HOMESERVER_HISTORY_SIZE = 20 + @ContributesBinding(AppScope::class) class DefaultAppPreferencesStore( private val buildMeta: BuildMeta, @@ -168,6 +174,20 @@ class DefaultAppPreferencesStore( } } + override fun getHomeserverHistoryFlow(): Flow> { + return store.data.map { prefs -> prefs.readHomeserverHistory() } + } + + override suspend fun addHomeserverToHistory(url: String) { + val normalized = url.trim().lowercase() + if (normalized.isEmpty()) return + store.edit { prefs -> + val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized }) + .take(MAX_HOMESERVER_HISTORY_SIZE) + prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER) + } + } + override fun getMessageSoundFlow(): Flow { return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) } } @@ -242,6 +262,13 @@ class DefaultAppPreferencesStore( } } +private fun Preferences.readHomeserverHistory(): List { + return this[homeserverHistoryKey] + ?.split(HOMESERVER_HISTORY_DELIMITER) + ?.filter { it.isNotEmpty() } + ?: emptyList() +} + private fun BuildMeta.defaultLogLevel(): LogLevel { return when (buildType) { BuildType.DEBUG -> LogLevel.TRACE diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt index c52d1648ac..4cf018371a 100644 --- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt +++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt @@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest { assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25) } + + @Test + fun `homeserver history is empty by default`() = runTest { + val store = createStore() + assertThat(store.getHomeserverHistoryFlow().first()).isEmpty() + } + + @Test + fun `adding homeservers keeps the most recent first`() = runTest { + val store = createStore() + + store.addHomeserverToHistory("https://matrix.org") + store.addHomeserverToHistory("https://example.com") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com", "https://matrix.org") + .inOrder() + } + + @Test + fun `adding an existing homeserver moves it to the front without duplicating`() = runTest { + val store = createStore() + + store.addHomeserverToHistory("https://matrix.org") + store.addHomeserverToHistory("https://example.com") + store.addHomeserverToHistory("https://matrix.org") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://matrix.org", "https://example.com") + .inOrder() + } + + @Test + fun `homeservers are normalised and deduplicated case-insensitively`() = runTest { + val store = createStore() + + store.addHomeserverToHistory(" HTTPS://Matrix.ORG ") + store.addHomeserverToHistory("https://matrix.org") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://matrix.org") + } + + @Test + fun `blank homeservers are ignored`() = runTest { + val store = createStore() + + store.addHomeserverToHistory(" ") + + assertThat(store.getHomeserverHistoryFlow().first()).isEmpty() + } + + @Test + fun `homeserver history is capped to the most recent entries`() = runTest { + val store = createStore() + + repeat(25) { index -> + store.addHomeserverToHistory("https://server$index.org") + } + + val history = store.getHomeserverHistoryFlow().first() + assertThat(history).hasSize(20) + assertThat(history.first()).isEqualTo("https://server24.org") + assertThat(history.last()).isEqualTo("https://server5.org") + } + + private fun createStore() = DefaultAppPreferencesStore( + buildMeta = buildMeta, + preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + ) } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index 6499e70223..e8d0b0bb8e 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet class InMemoryAppPreferencesStore( @@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore( liveLocationMinimumDistanceUpdate: Int = 10, logLevel: LogLevel = LogLevel.INFO, traceLogPacks: Set = emptySet(), + homeserverHistory: List = emptyList(), messageSound: NotificationSound = NotificationSound.SystemDefault, messageSoundChannelVersion: Int = 0, messageSoundDisplayName: String? = null, @@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore( private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val logLevel = MutableStateFlow(logLevel) private val tracingLogPacks = MutableStateFlow(traceLogPacks) + private val homeserverHistory = MutableStateFlow(homeserverHistory) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) private val messageSound = MutableStateFlow(messageSound) @@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore( return tracingLogPacks } + override fun getHomeserverHistoryFlow(): Flow> { + return homeserverHistory + } + + override suspend fun addHomeserverToHistory(url: String) { + val normalized = url.trim().lowercase() + if (normalized.isEmpty()) return + homeserverHistory.update { current -> + (listOf(normalized) + current.filter { it != normalized }).take(10) + } + } + override fun getMessageSoundFlow(): Flow { return messageSound } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index b9082c50f6..8615485bba 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -1,3 +1,5 @@ +import config.BuildTimeConfig +import extension.buildConfigFieldStr import extension.setupDependencyInjection import extension.testCommonDependencies @@ -17,6 +19,33 @@ plugins { android { namespace = "io.element.android.libraries.push.impl" + buildFeatures { + buildConfig = true + } + + buildTypes { + val defaultPusherAppId = "im.vector.app.android" + getByName("release") { + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_RELEASE ?: defaultPusherAppId, + ) + } + getByName("debug") { + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_DEBUG ?: defaultPusherAppId, + ) + } + register("nightly") { + matchingFallbacks += listOf("release") + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_NIGHTLY ?: defaultPusherAppId, + ) + } + } + testOptions { unitTests { isIncludeAndroidResources = true diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt index 35482aa164..7aa519a791 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appconfig.PushConfig import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta @@ -74,7 +73,7 @@ class DefaultPusherSubscriber( ): SetHttpPusherData = SetHttpPusherData( pushKey = pushKey, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, // TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()) profileTag = DEFAULT_PUSHER_FILE_TAG + "_", // TODO localeProvider.current().language @@ -104,7 +103,7 @@ class DefaultPusherSubscriber( .unsetHttpPusher( unsetHttpPusherData = UnsetHttpPusherData( pushKey = pushKey, - appId = PushConfig.PUSHER_APP_ID + appId = BuildConfig.PUSHER_APP_ID ) ) .onSuccess { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt index 4ed8ca8338..6921c050db 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -10,9 +10,9 @@ package io.element.android.libraries.push.impl.test import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appconfig.PushConfig import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.BuildConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.api.Config @@ -28,7 +28,7 @@ class DefaultTestPush( pushGatewayNotifyRequest.execute( PushGatewayNotifyRequest.Params( url = config.url, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, pushKey = config.pushKey, eventId = TEST_EVENT_ID, roomId = TEST_ROOM_ID, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt index 8cb1e2a7f6..76479720be 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.push.impl import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.PushConfig import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData @@ -96,7 +95,7 @@ class DefaultPusherSubscriberTest { value( SetHttpPusherData( pushKey = "aPushKey", - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, url = "aGateway", appDisplayName = "MyApp", deviceDisplayName = "MyDevice", @@ -163,7 +162,7 @@ class DefaultPusherSubscriberTest { value( UnsetHttpPusherData( pushKey = "aPushKey", - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, ), ) ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt index 9ab0f16b62..3722c09d99 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -8,7 +8,7 @@ package io.element.android.libraries.push.impl.test -import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.BuildConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.test.aSessionPushConfig import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -33,7 +33,7 @@ class DefaultTestPushTest { value( PushGatewayNotifyRequest.Params( url = aConfig.url, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, pushKey = aConfig.pushKey, eventId = DefaultTestPush.TEST_EVENT_ID, roomId = DefaultTestPush.TEST_ROOM_ID, diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md index a2a7ad97ed..d9732af0d0 100644 --- a/libraries/pushproviders/firebase/README.md +++ b/libraries/pushproviders/firebase/README.md @@ -2,6 +2,6 @@ ## Configuration -In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. +In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. -To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `build.gradle.kts` files. +To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `firebase.xml` files. diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index 1a8e73a4da..33e88dd94c 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -8,7 +8,6 @@ @file:Suppress("UnstableApiUsage") -import config.BuildTimeConfig import extension.setupDependencyInjection import extension.testCommonDependencies import org.gradle.kotlin.dsl.withType @@ -28,27 +27,10 @@ android { buildTypes { getByName("release") { consumerProguardFiles("consumer-proguard-rules.pro") - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE, - ) - } - getByName("debug") { - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG, - ) } register("nightly") { consumerProguardFiles("consumer-proguard-rules.pro") matchingFallbacks += listOf("release") - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY, - ) } } } diff --git a/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml new file mode 100644 index 0000000000..e96f163603 --- /dev/null +++ b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:def0a4e454042e9b00427c + diff --git a/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml new file mode 100644 index 0000000000..7275ff2ddd --- /dev/null +++ b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:e17435e0beb0303000427c + diff --git a/libraries/pushproviders/firebase/src/release/res/values/firebase.xml b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml new file mode 100644 index 0000000000..3a9a4330d1 --- /dev/null +++ b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:d097de99a4c23d2700427c + diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 6a10ece336..fcb89247b5 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -245,6 +245,20 @@ fun TextComposer( canSendTextMessage, ) { when { + composerMode.isEditing -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, + endButtonClick = { + if (canSendTextMessage) { + onSendMessage() + } + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = canSendTextMessage, + isEditing = true, + ) + }, + ) !canSendTextMessage -> when (voiceMessageState) { VoiceMessageState.Idle -> EndButtonParams( @@ -296,18 +310,6 @@ fun TextComposer( ) } } - composerMode.isEditing -> EndButtonParams( - endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, - endButtonClick = { - onSendMessage() - }, - endButtonContent = @Composable { - SendButtonIcon( - canSendMessage = true, - isEditing = true, - ) - }, - ) else -> EndButtonParams( endButtonContentDescriptionResId = CommonStrings.action_send_message, endButtonClick = { diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt new file mode 100644 index 0000000000..c979e8c909 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * Copyright 2026 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.textcomposer + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.wysiwyg.display.TextDisplay +import org.junit.Test + +class TextComposerEndButtonTest : RobolectricTest() { + @Test + fun `editing a message with text shows the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "Some text", composerMode = aMessageComposerModeEdit()) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + } + + @Test + fun `emptying the composer while editing keeps the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = aMessageComposerModeEdit()) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist() + } + + @Test + fun `emptying the composer while editing a caption keeps the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = aMessageComposerModeEditCaption(content = "An existing caption")) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist() + } + + @Test + fun `an empty composer outside of edition still offers to record a voice message`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = MessageComposerMode.Normal) + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertIsDisplayed() + } + + private fun AndroidComposeUiTest.getString(resId: Int): String { + return activity!!.getString(resId) + } + + private fun AndroidComposeUiTest.setTextComposer( + text: String, + composerMode: MessageComposerMode, + ) { + setContent { + TextComposer( + state = aTextEditorStateMarkdown(initialText = text, initialFocus = true), + voiceMessageState = VoiceMessageState.Idle, + composerMode = composerMode, + onRequestFocus = {}, + onSendMessage = {}, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onError = {}, + onTyping = {}, + onReceiveSuggestion = {}, + onSelectRichContent = null, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + ) + } + } +} diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index 223b8aa457..8a28af1e9c 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -516,14 +516,6 @@ Motivo: %1$s." "%1$s Android" "Agitar con fuerza para informar de un error" "Captura de pantalla" - "Iniciar sesión" - "Crear cuenta" - "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)." - "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)." - "Proveedor de cuentas" - "ejemplo.com" - "Introduce tu proveedor de cuenta" - "Elegir proveedor de cuentas" "%1$s: %2$s" "Opciones" "Eliminar %1$s" diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index 14800b9ca3..c24e8652f6 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -507,14 +507,6 @@ Kas sa oled kindel, et soovid jätkata?" "%1$s Android" "Veast teatamiseks raputa nutiseadet ägedalt" "Ekraanitõmmis" - "Logi sisse" - "Loo kasutajakonto" - "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)." - "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)." - "Teenusepakkuja" - "domeeninäidis.ee" - "Sisesta oma kasutajakonto teenusepakkuja" - "Vali kasutajakonto teenusepakkuja" "%1$s: %2$s" "Valikud" "Kustuta: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index b1cc4889a8..11ca38edf0 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -516,14 +516,6 @@ Haluatko varmasti jatkaa?" "%1$s Android" "Raivostunut ravistaminen ilmoittaa virheestä" "Näyttökuva" - "Kirjaudu sisään" - "Luo tili" - "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)." - "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)." - "Palveluntarjoaja" - "example.com" - "Anna palveluntarjoajasi" - "Valitse palveluntarjoaja" "%1$s: %2$s" "Vaihtoehdot" "Poista %1$s" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 105ced1ad5..6aced99b00 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -516,14 +516,6 @@ Raison : %1$s." "%1$s Android" "Rageshake pour signaler un problème" "Capture d’écran" - "Se connecter" - "Créer un compte" - "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)." - "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)." - "Fournisseur de compte" - "exemple.com" - "Saisissez votre fournisseur de compte" - "Choisissez un fournisseur de compte" "%1$s: %2$s" "Options" "Supprimer %1$s" diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 1ee4275703..f0ac7785da 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -528,14 +528,6 @@ Czy na pewno chcesz kontynuować?" "%1$s Android" "Wstrząśnij gniewnie, aby zgłosić błąd" "Zrzut ekranu" - "Zaloguj" - "Utwórz konto" - "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)" - "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)." - "Dostawca konta" - "przykład.com" - "Wprowadź dostawcę swojego konta" - "Wybierz dostawcę konta" "%1$s: %2$s" "Opcje" "Usuń %1$s" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 71b5ab7041..cd18373e6a 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -528,14 +528,6 @@ Sunteți sigur că doriți să continuați?" "%1$s Android" "Rageshake pentru a raporta erori" "Captură de ecran" - "Autentificare" - "Creați un cont" - "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)." - "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)." - "Furnizor de cont" - "exemplu.ro" - "Introduceți furnizorul contului dumneavoastră" - "Alegeți un furnizor de cont" "%1$s: %2$s" "Opțiuni" "Ștergeți %1$s" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index 8545325603..e00a269473 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -528,14 +528,6 @@ "%1$s Android" "Повідомляти про помилку потрусивши телефон" "Знімок екрана" - "Увійти" - "Створити обліковий запис" - "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)." - "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)." - "Постачальник облікового запису" - "example.com" - "Вкажіть постачальника вашого облікового запису" - "Оберіть постачальника облікового запису" "%1$s: %2$s" "Варіанти" "Вилучити %1$s" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index b363ba9498..03929c0182 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -504,14 +504,6 @@ "%1$s Android" "摇一摇以报告 bug" "屏幕截图" - "登录" - "创建账户" - "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。" - "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。" - "账户提供者" - "example.com" - "输入你的账户提供者" - "选择账户提供者" "%1$s:%2$s" "选项" "移除 %1$s" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 0d27ff5a9f..92899ba9da 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -516,14 +516,6 @@ Are you sure you want to continue?" "%1$s Android" "Rageshake to report bug" "Screenshot" - "Sign in" - "Create account" - "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." - "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." - "Account provider" - "example.com" - "Enter your account provider" - "Choose an account provider" "%1$s: %2$s" "Options" "Remove %1$s" diff --git a/libraries/voicerecorder/test/build.gradle.kts b/libraries/voicerecorder/test/build.gradle.kts index c0f74b71e8..f54ef0e8d3 100644 --- a/libraries/voicerecorder/test/build.gradle.kts +++ b/libraries/voicerecorder/test/build.gradle.kts @@ -19,6 +19,5 @@ dependencies { implementation(projects.tests.testutils) implementation(libs.coroutines.test) - implementation(libs.test.truth) implementation(projects.libraries.core) } diff --git a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt index ec62227857..b735bb9fa5 100644 --- a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt +++ b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt @@ -8,13 +8,13 @@ package io.element.android.libraries.voicerecorder.test -import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.yield import java.io.File import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -23,51 +23,47 @@ import kotlin.time.TestTimeSource class FakeVoiceRecorder( private val timeSource: TestTimeSource = TestTimeSource(), private val recordingDuration: Duration = 0.seconds, - private val levels: List = listOf(0.1f, 0.2f) + private val levels: List = listOf(0.1f, 0.2f), + val waveform: List = A_WAVEFORM, + private val startRecordResult: () -> Unit = { lambdaError() }, + private val stopRecordResult: (Boolean) -> Unit = { lambdaError() }, + private val deleteRecordingResult: () -> Unit = { lambdaError() }, ) : VoiceRecorder { private val _state = MutableStateFlow(VoiceRecorderState.Idle) override val state: StateFlow = _state - private var curRecording: File? = null + private val levelInterval = if (levels.isEmpty()) Duration.ZERO else recordingDuration / levels.size - private var securityException: SecurityException? = null + private var currentRecording: File? = null + private var lastRecordingId = 0 + private var activeRecordingId = 0 - private var startedCount = 0 - private var stoppedCount = 0 - private var deletedCount = 0 - - var waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f) override suspend fun startRecord() { - startedCount += 1 + startRecordResult() + if (activeRecordingId != 0) return + val recordingId = ++lastRecordingId + activeRecordingId = recordingId + currentRecording = File("file.ogg") val startedAt = timeSource.markNow() - securityException?.let { throw it } - - if (curRecording != null) { - error("Previous recording was not cleared") - } - curRecording = File("file.ogg") - - timeSource += recordingDuration for (i in 1..levels.size) { + delay(levelInterval) + if (activeRecordingId != recordingId) return + timeSource += levelInterval _state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i))) - yield() } } - override suspend fun stopRecord( - cancelled: Boolean - ) { - stoppedCount++ - + override suspend fun stopRecord(cancelled: Boolean) { + stopRecordResult(cancelled) + activeRecordingId = 0 if (cancelled) { deleteRecording() } - _state.emit( - when (curRecording) { + when (val file = currentRecording) { null -> VoiceRecorderState.Idle else -> VoiceRecorderState.Finished( - file = curRecording!!, + file = file, mimeType = MimeTypes.Ogg, duration = recordingDuration, waveform = waveform, @@ -77,25 +73,11 @@ class FakeVoiceRecorder( } override suspend fun deleteRecording() { - deletedCount++ - curRecording = null - - _state.emit( - VoiceRecorderState.Idle - ) - } - - fun assertCalls( - started: Int = 0, - stopped: Int = 0, - deleted: Int = 0, - ) { - assertThat(startedCount).isEqualTo(started) - assertThat(stoppedCount).isEqualTo(stopped) - assertThat(deletedCount).isEqualTo(deleted) - } - - fun givenThrowsSecurityException(exception: SecurityException) { - this.securityException = exception + deleteRecordingResult() + activeRecordingId = 0 + currentRecording = null + _state.emit(VoiceRecorderState.Idle) } } + +private val A_WAVEFORM = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f) diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt deleted file mode 100644 index c7bc602dde..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.api - -/** - * Server-driven settings for a user-chosen recovery passphrase, advertised under the well-known - * `custom_recovery_passphrase` key. Today the only rule is a minimum character count; additional - * settings can be added here as the schema grows. - */ -data class CustomRecoveryPassphrase( - val minCharacterCount: Int, -) { - /** True when [input] meets every active rule. */ - fun isSatisfiedBy(input: String): Boolean = isSatisfiedBy(input.length) - - /** True when an input of [length] characters meets every active rule. */ - fun isSatisfiedBy(length: Int): Boolean = length >= minCharacterCount -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnownParser.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnownParser.kt deleted file mode 100644 index 1466a5b422..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnownParser.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.api - -/** - * Parses the raw JSON of an Element `.well-known` document. - */ -fun interface ElementWellKnownParser { - /** - * Parses the document, failing when it is not valid JSON; unknown fields are ignored so a newer server does not break an older client. - * - * @param json the raw document as served by the server. - */ - fun parse(json: String): Result -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt deleted file mode 100644 index 93256fa6f1..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.api - -/** - * On-disk cache of the Element `.well-known` documents already fetched, so the app has a configuration to work with while offline. - */ -interface ElementWellknownStore { - /** - * Reads the cached document of a domain. - * Entries older than a day are reported as outdated rather than missing, so the caller can use them while refreshing; - * a document that no longer parses is reported as an error. - * - * @param domain the server whose cached document is requested. - */ - suspend fun get(domain: String): WellknownRetrieverResult - - /** - * Stores the document of a domain and stamps it as fetched now, replacing any previous entry. - * - * @param domain the server the document belongs to. - * @param wellknown the raw document, stored as-is rather than parsed. - */ - suspend fun update(domain: String, wellknown: String): Result - - /** - * Removes the cached document of a domain, used when the stored value turns out to be unusable. - * - * @param domain the server whose entry is removed. - */ - suspend fun delete(domain: String): Result - - /** - * Creates a store, one per configuration source so that their entries never collide. - */ - fun interface Factory { - /** - * @param prefix namespaces the cache keys, or `null` for the default well-known endpoint. - */ - fun create(prefix: String?): ElementWellknownStore - } -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/EnterpriseRemoteConfigSource.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/EnterpriseRemoteConfigSource.kt deleted file mode 100644 index 147f31269d..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/EnterpriseRemoteConfigSource.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.api - -/** - * The source from which the enterprise remote configuration is retrieved. - */ -enum class EnterpriseRemoteConfigSource { - /** - * Retrieve the enterprise remote configuration from the Element Server Suite configuration (ESS_CONFIG) endpoint. - */ - ESS_CONFIG, - - /** - * Retrieve the enterprise remote configuration from the Element server's well-known endpoint. - */ - WELLKNOWN_ENDPOINT, -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt deleted file mode 100644 index 9e265bc213..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt +++ /dev/null @@ -1,39 +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.libraries.wellknown.api - -import io.element.android.libraries.matrix.api.UrlContentFetcher - -/** - * Retrieves the Element specific `.well-known` configuration of a server, which is how a deployment advertises its own settings. - */ -interface WellknownRetriever { - /** - * Returns the configuration of a host, served from the local cache when possible. - * A cached value that has gone stale is returned as outdated while a refresh runs in the background, so this rarely waits on the network. - * An enterprise build with a hardcoded configuration always short-circuits and returns that instead. - * - * @param host the server to look up; a full URL is accepted and reduced to its host. - * @param source which endpoint to read the configuration from, which also selects the cache it is stored in. - */ - suspend fun getElementWellKnown( - host: String, - source: EnterpriseRemoteConfigSource - ): WellknownRetrieverResult - - /** - * Creates a retriever, which needs a fetcher because the configuration is read over HTTP. - */ - interface Factory { - /** - * @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works. - */ - fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultElementWellKnownParser.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultElementWellKnownParser.kt deleted file mode 100644 index 4cf72366e0..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultElementWellKnownParser.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.impl - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser - -@ContributesBinding(AppScope::class) -class DefaultElementWellKnownParser( - private val jsonProvider: JsonProvider, -) : ElementWellKnownParser { - override fun parse(json: String): Result { - return runCatchingExceptions { - val decoder = jsonProvider() - decoder.decodeFromString(json).map() - } - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt deleted file mode 100644 index 4d58e70db4..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.impl - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.api.CacheStore -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.services.toolbox.api.systemclock.SystemClock - -@AssistedInject -class DefaultWellKnownStore( - @Assisted - private val prefix: String?, - private val cacheStore: CacheStore, - private val elementWellKnownParser: ElementWellKnownParser, - private val systemClock: SystemClock, -) : ElementWellknownStore { - @AssistedFactory - @ContributesBinding(AppScope::class) - fun interface Factory : ElementWellknownStore.Factory { - override fun create(prefix: String?): DefaultWellKnownStore - } - override suspend fun get(domain: String): WellknownRetrieverResult { - return runCatchingExceptions { - val cachedData = cacheStore.getData(key(domain)) - if (cachedData != null) { - val data = elementWellKnownParser.parse(cachedData.value).getOrThrow() - if (systemClock.epochMillis() > cachedData.updatedAt + CACHE_VALIDITY_MILLIS) { - WellknownRetrieverResult.Outdated(data) - } else { - WellknownRetrieverResult.Success(data) - } - } else { - WellknownRetrieverResult.NotFound - } - }.recover { - WellknownRetrieverResult.Error(it as Exception) - }.getOrThrow() - } - - override suspend fun update(domain: String, wellknown: String): Result { - return runCatchingExceptions { - cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis())) - } - } - - override suspend fun delete(domain: String): Result { - return runCatchingExceptions { - cacheStore.deleteData(key(domain)) - } - } - - private fun key(domain: String): String = if (prefix != null) { - "$prefix-$domain" - } else { - "https://$domain/.well-known/element/element.json" - } - - companion object { - // 1 day - private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt deleted file mode 100644 index 60e8c621e2..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt +++ /dev/null @@ -1,142 +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.libraries.wellknown.impl - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.androidutils.text.takeIfNotBlank -import io.element.android.libraries.core.extensions.mapCatchingExceptions -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.exception.ClientException -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import timber.log.Timber -import java.net.URL - -@AssistedInject -class DefaultWellknownRetriever( - private val elementWellknownStoreFactory: ElementWellknownStore.Factory, - private val enterpriseService: EnterpriseService, - private val elementWellKnownParser: ElementWellKnownParser, - @Assisted private val urlContentFetcher: UrlContentFetcher, - @AppCoroutineScope private val coroutineScope: CoroutineScope, -) : WellknownRetriever { - @ContributesBinding(AppScope::class) - @AssistedFactory - fun interface Factory : WellknownRetriever.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): DefaultWellknownRetriever - } - - override suspend fun getElementWellKnown( - host: String, - source: EnterpriseRemoteConfigSource - ): WellknownRetrieverResult { - val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown() - if (overriddenElementWellKnown != null) { - return WellknownRetrieverResult.Success(overriddenElementWellKnown) - } - - // The passed `host` function may be a full URL, in that case we're only interested in the actual host - val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() } - .getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) } - ?: host - - // We instantiate different stores for different sources, so that we can cache them separately. - // The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT. - val prefix = when (source) { - EnterpriseRemoteConfigSource.ESS_CONFIG -> "ess_config" - EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> null - } - val store = elementWellknownStoreFactory.create(prefix) - return when (val cacheData = store.get(checkedHost)) { - is WellknownRetrieverResult.Success -> { - Timber.d("Using cached well-known for domain $checkedHost") - cacheData - } - is WellknownRetrieverResult.Outdated -> { - // Return the outdated data but refresh in the background - // If the cache is missing or outdated, trigger a refresh in background but still return the cached value - Timber.d("Outdated cached well-known for domain $checkedHost, returning existing value and fetching new one from network") - coroutineScope.launch { fetchElementWellKnown(checkedHost, source, store) } - cacheData - } - is WellknownRetrieverResult.NotFound -> { - // Try to fetch from the server - Timber.d("No cached well-known for domain $checkedHost, fetching from network") - fetchElementWellKnown(checkedHost, source, store) - } - is WellknownRetrieverResult.Error -> { - // Return the error - Timber.e(cacheData.exception, "Error retrieving well-known for domain $checkedHost") - cacheData.exception.toWellknownRetrieverResult() - } - } - } - - private suspend fun fetchElementWellKnown( - host: String, - source: EnterpriseRemoteConfigSource, - store: ElementWellknownStore - ): WellknownRetrieverResult { - return urlContentFetcher - .getUrl(url(host, source)) - .mapCatchingExceptions { - val data = String(it) - val parsed = elementWellKnownParser.parse(data).getOrThrow() - // Also store in cache, if valid - store.update(host, data) - .onFailure { exception -> - Timber.e(exception, "Failed to parse cached Element .well-known data for $host, deleting cache") - store.delete(host) - } - parsed - } - .toWellknownRetrieverResult(host) - } - - private fun Result.toWellknownRetrieverResult(host: String): WellknownRetrieverResult = fold( - onSuccess = { - WellknownRetrieverResult.Success(it) - }, - onFailure = { - Timber.e(it, "Failed to retrieve Element .well-known from $host") - // This check on message value is not ideal but this is what we got from the SDK. - it.toWellknownRetrieverResult() - } - ) - - private fun Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult { - return if ((this as? ClientException.Generic)?.message?.contains("404") == true) { - WellknownRetrieverResult.NotFound - } else { - WellknownRetrieverResult.Error(this as Exception) - } - } - - private fun url(host: String, source: EnterpriseRemoteConfigSource): String { - val elementWellKnownUrl = "https://$host/.well-known/element/element.json" - return when (source) { - EnterpriseRemoteConfigSource.ESS_CONFIG -> enterpriseService.essConfigEndpointUrl(host) ?: elementWellKnownUrl - EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> elementWellKnownUrl - } - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt deleted file mode 100644 index 317109a1c0..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ /dev/null @@ -1,71 +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.libraries.wellknown.impl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Example: - *
- * {
- *     "registration_helper_url": "https://element.io",
- *     "enforce_element_pro": true,
- *     "rageshake_url": "https://example.org/rageshake",
- *     "brand_color": "#FF0000",
- *     "notification_sound": "ring.flac",
- *     "idp_app_scheme": "io.element.app",
- *     "custom_recovery_passphrase": {
- *         "min_character_count": 8
- *     }
- * }
- * 
- * . - */ -@Serializable -data class InternalElementWellKnown( - @SerialName("registration_helper_url") - val registrationHelperUrl: String? = null, - @SerialName("enforce_element_pro") - val enforceElementPro: Boolean? = null, - @SerialName("rageshake_url") - val rageshakeUrl: String? = null, - @SerialName("brand_color") - val brandColor: String? = null, - @SerialName("notification_sound") - val notificationSound: String? = null, - @SerialName("idp_app_scheme") - val identityProviderAppScheme: String? = null, - @SerialName("custom_recovery_passphrase") - val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null, - @SerialName("content_scanner_url") - val contentScannerUrl: String? = null, - @SerialName("force_disable_e2ee") - val forceDisableE2EE: Boolean? = null, - @SerialName("map_tiler") - val mapTilerConfig: InternalMapTilerConfig? = null, -) - -@Serializable -data class InternalCustomRecoveryPassphrase( - @SerialName("min_character_count") - val minCharacterCount: Int? = null, -) - -@Serializable -data class InternalMapTilerConfig( - @SerialName("api_key") - val apiKey: String, - @SerialName("light_style_id") - val lightStyleId: String? = null, - @SerialName("dark_style_id") - val darkStyleId: String? = null, - @SerialName("base_url") - val baseUrl: String? = null, -) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnown.kt deleted file mode 100644 index bf43356c45..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnown.kt +++ /dev/null @@ -1,34 +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.libraries.wellknown.impl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery - *
- * {
- *     "m.homeserver": {
- *         "base_url": "https://matrix.org"
- *     },
- *     "m.identity_server": {
- *         "base_url": "https://vector.im"
- *     },
- * }
- * 
- * . - */ -@Serializable -data class InternalWellKnown( - @SerialName("m.homeserver") - val homeServer: InternalWellKnownBaseConfig? = null, - @SerialName("m.identity_server") - val identityServer: InternalWellKnownBaseConfig? = null, -) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnownBaseConfig.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnownBaseConfig.kt deleted file mode 100644 index 342cd43395..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnownBaseConfig.kt +++ /dev/null @@ -1,27 +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.libraries.wellknown.impl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery - *
- * {
- *     "base_url": "https://element.io"
- * }
- * 
- * . - */ -@Serializable -data class InternalWellKnownBaseConfig( - @SerialName("base_url") - val baseURL: String? = null -) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt deleted file mode 100644 index 254036c5c3..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ /dev/null @@ -1,59 +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.libraries.wellknown.impl - -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.MapTilerConfig -import timber.log.Timber - -private val loggerTag = LoggerTag("Wellknown") - -/** - * Floor for the recovery passphrase minimum length. A value of 1 guarantees the derived passphrase is - * never empty (an empty passphrase derives a recoverable but effectively unprotected secret-storage - * key). Anything stronger than non-empty is left to the homeserver operator's configuration. - */ -private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1 - -fun InternalElementWellKnown.map() = ElementWellKnown( - registrationHelperUrl = registrationHelperUrl, - enforceElementPro = enforceElementPro, - rageshakeUrl = rageshakeUrl, - brandColor = brandColor, - notificationSound = notificationSound, - identityProviderAppScheme = identityProviderAppScheme, - customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(), - contentScannerUrl = contentScannerUrl, - forceDisableE2EE = forceDisableE2EE, - mapTilerConfig = mapTilerConfig?.let { - MapTilerConfig( - apiKey = it.apiKey, - lightStyleId = it.lightStyleId, - darkStyleId = it.darkStyleId, - baseUrl = it.baseUrl, - ) - } -) - -private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase { - // Whenever the homeserver advertises the settings block we run the custom passphrase flow, flooring - // the minimum at 1 so the passphrase can never be empty even if the server omits min_character_count - // or advertises a non-positive value. The operator owns any stronger minimum. - val min = (minCharacterCount ?: MINIMUM_PASSPHRASE_CHARACTER_COUNT).coerceAtLeast(MINIMUM_PASSPHRASE_CHARACTER_COUNT) - if (min != minCharacterCount) { - Timber.tag(loggerTag.value).w( - "custom_recovery_passphrase.min_character_count was %s; flooring to %d", - minCharacterCount, - min, - ) - } - return CustomRecoveryPassphrase(minCharacterCount = min) -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/SessionWellknownModule.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/SessionWellknownModule.kt deleted file mode 100644 index 1587e95765..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/SessionWellknownModule.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.impl - -import dev.zacsweers.metro.BindingContainer -import dev.zacsweers.metro.ContributesTo -import dev.zacsweers.metro.Provides -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.WellknownRetriever -import kotlinx.coroutines.CoroutineScope - -@BindingContainer -@ContributesTo(SessionScope::class) -object SessionWellknownModule { - @Provides - fun provideWellKnownRetriever( - elementWellknownStoreFactory: ElementWellknownStore.Factory, - enterpriseService: EnterpriseService, - elementWellKnownParser: ElementWellKnownParser, - urlContentFetcher: UrlContentFetcher, - @SessionCoroutineScope coroutineScope: CoroutineScope, - ): WellknownRetriever { - return DefaultWellknownRetriever( - elementWellknownStoreFactory = elementWellknownStoreFactory, - enterpriseService = enterpriseService, - elementWellKnownParser = elementWellKnownParser, - urlContentFetcher = urlContentFetcher, - coroutineScope = coroutineScope - ) - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/WellknownAPI.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/WellknownAPI.kt deleted file mode 100644 index 54528f7c42..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/WellknownAPI.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.impl - -import retrofit2.http.GET - -internal interface WellknownAPI { - @GET(".well-known/matrix/client") - suspend fun getWellKnown(): InternalWellKnown - - @GET(".well-known/element/element.json") - suspend fun getElementWellKnown(): InternalElementWellKnown -} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt deleted file mode 100644 index d4e7ac112e..0000000000 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.api - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class CustomRecoveryPassphraseTest { - @Test - fun `empty input never satisfies a positive minimum`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 1).isSatisfiedBy("")).isFalse() - } - - @Test - fun `input shorter than minimum is not satisfied`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse() - } - - @Test - fun `input exactly at minimum is satisfied`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue() - } - - @Test - fun `input longer than minimum is satisfied`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue() - } -} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt deleted file mode 100644 index dc101c41a0..0000000000 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.impl - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.wellknown.test.anElementWellKnown -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP -import io.element.android.services.toolbox.test.systemclock.FakeSystemClock -import kotlinx.coroutines.test.runTest -import org.junit.Test - -private const val A_DOMAIN = "matrix.example.com" -private const val A_CACHE_KEY = "https://$A_DOMAIN/.well-known/element/element.json" - -// 1 day in millis -private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L - -class DefaultWellKnownStoreTest { - private val jsonProvider = DefaultJsonProvider() - - @Test - fun `get returns NotFound when cache is empty`() = runTest { - val sut = createDefaultWellKnownStore() - assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound) - } - - @Test - fun `get returns Success when cache has fresh data`() = runTest { - val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") - val jsonString = jsonProvider().encodeToString(wellKnown) - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map())) - } - - @Test - fun `get returns Outdated when cached data has expired`() = runTest { - val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") - val jsonString = jsonProvider().encodeToString(wellKnown) - // Store data at timestamp 0, but clock is past the validity window - val expiredTimestamp = 0L - val nowTimestamp = expiredTimestamp + CACHE_VALIDITY_MILLIS + 1 - val clock = FakeSystemClock(epochMillisResult = nowTimestamp) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, expiredTimestamp)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isEqualTo(WellknownRetrieverResult.Outdated(wellKnown.map())) - } - - @Test - fun `get returns Error when cached data is invalid JSON`() = runTest { - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData("not valid json at all", A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `update stores data and get returns Success`() = runTest { - val wellKnown = """ - { - "registration_helper_url": "https://element.io", - "enforce_element_pro": true, - "rageshake_url": "https://example.org/rageshake", - "brand_color": "#FF0000", - "notification_sound": "ring.flac", - "idp_app_scheme": "io.element.app", - "custom_recovery_passphrase": { - "min_character_count": 8 - } - } - """.trimIndent() - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore() - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val updateResult = sut.update(A_DOMAIN, wellKnown) - assertThat(updateResult.isSuccess).isTrue() - - val result = sut.get(A_DOMAIN) - assertThat(result).isEqualTo(WellknownRetrieverResult.Success(anElementWellKnown( - registrationHelperUrl = "https://element.io", - enforceElementPro = true, - rageshakeUrl = "https://example.org/rageshake", - brandColor = "#FF0000", - notificationSound = "ring.flac", - identityProviderAppScheme = "io.element.app", - customRecoveryPassphrase = CustomRecoveryPassphrase(8), - ))) - } - - @Test - fun `delete removes data and get returns NotFound`() = runTest { - val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") - val jsonString = jsonProvider().encodeToString(wellKnown) - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val deleteResult = sut.delete(A_DOMAIN) - assertThat(deleteResult.isSuccess).isTrue() - - assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound) - } - - @Test - fun `get returns data at the exact cache validity boundary as Success`() = runTest { - val wellKnown = InternalElementWellKnown(notificationSound = "ping.flac") - val jsonString = jsonProvider().encodeToString(wellKnown) - // Exactly at the boundary: epochMillis == updatedAt + CACHE_VALIDITY_MILLIS → not expired - val storedAt = 1000L - val nowAtBoundary = storedAt + CACHE_VALIDITY_MILLIS - val clock = FakeSystemClock(epochMillisResult = nowAtBoundary) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, storedAt)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map())) - } - - @Test - fun `prefix is used`() = runTest { - val prefix = "test-prefix" - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore( - initialData = mapOf("$prefix-$A_DOMAIN" to CacheData("{}", A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(prefix = prefix, cacheStore = cacheStore, systemClock = clock) - - assertThat(sut.get(A_DOMAIN)).isInstanceOf(WellknownRetrieverResult.Success::class.java) - } - - private fun createDefaultWellKnownStore( - prefix: String? = null, - cacheStore: InMemoryCacheStore = InMemoryCacheStore(), - systemClock: FakeSystemClock = FakeSystemClock(), - elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(jsonProvider), - ) = DefaultWellKnownStore( - prefix = prefix, - cacheStore = cacheStore, - systemClock = systemClock, - elementWellKnownParser = elementWellKnownParser, - ) -} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt deleted file mode 100644 index a58aa0a347..0000000000 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt +++ /dev/null @@ -1,241 +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. - */ - -@file:OptIn(ExperimentalCoroutinesApi::class) - -package io.element.android.libraries.wellknown.impl - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.wellknown.test.FakeElementWellknownStore -import io.element.android.features.wellknown.test.aMapTilerConfig -import io.element.android.features.wellknown.test.anElementWellKnown -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.exception.ClientException -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.net.URL - -class DefaultWellknownRetrieverTest { - @Test - fun `get element wellknown calls resolver and parser`() = runTest { - val parser = lambdaRecorder> { Result.success(anElementWellKnown()) } - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - val sut = createDefaultWellknownRetriever( - elementWellKnownParser = parser, - urlResolver = resolver, - ) - - sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT) - - resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL)) - parser.assertions().isCalledOnce().with(value(WELLKNOWN_CONTENT)) - } - - @Test - fun `get element wellknown parser error`() = runTest { - val sut = createDefaultWellknownRetriever( - elementWellKnownParser = { Result.failure(IllegalStateException("Invalid JSON")) }, - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown network error`() = runTest { - val resolver = SpyUrlContentFetcher { Result.failure(IllegalStateException("Network error")) } - val sut = createDefaultWellknownRetriever(urlResolver = resolver) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown 404 http error counts as not found`() = runTest { - val resolver = SpyUrlContentFetcher { Result.failure(ClientException.Generic("Http error: 404", null)) } - val sut = createDefaultWellknownRetriever(urlResolver = resolver) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.NotFound::class.java) - } - - @Test - fun `get element wellknown will return an error if the provided host is not valid`() = runTest { - val sut = createDefaultWellknownRetriever() - assertThat(sut.getElementWellKnown("!not valid", EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown will return a cached value if present instead of fetching`() = runTest { - val host = URL(WELLKNOWN_URL).host.ensureProtocol() - val sut = createDefaultWellknownRetriever( - cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Success(expectedElementWellKnown))) } - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Success::class.java) - } - - @Test - fun `get element wellknown will return an outdated value first then fetch the current one`() = runTest { - val host = URL(WELLKNOWN_URL).host.ensureProtocol() - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - val sut = createDefaultWellknownRetriever( - urlResolver = resolver, - cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Outdated(expectedElementWellKnown))) } - ) - // The first call returns the outdated value - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Outdated::class.java) - // The resolver is not called yet because the fetch is done in the background - resolver.assertions().isNeverCalled() - - // We give it some time to run the background fetch - runCurrent() - - // The resolver is called during the background fetch - resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL)) - } - - @Test - fun `get element wellknown will return an error if it happens when trying to retrieve the cached value`() = runTest { - val host = URL(WELLKNOWN_URL).host.ensureProtocol() - val sut = createDefaultWellknownRetriever( - cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Error(IllegalStateException("BOOM")))) } - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown will return an error if it fails to serialize the retrieved value into the cache`() = runTest { - val sut = createDefaultWellknownRetriever( - elementWellKnownParser = { Result.failure(IllegalStateException("BOOM")) }, - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `WELLKNOWN_ENDPOINT source creates a store with no prefix`() = runTest { - val storeFactory = lambdaRecorder { FakeElementWellknownStore() } - val sut = createDefaultWellknownRetriever(cacheStoreFactory = storeFactory) - - sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT) - - storeFactory.assertions().isCalledOnce().with(value(null)) - } - - @Test - fun `ESS_CONFIG source creates a store with ess prefix and checks the ESS config endpoint`() = runTest { - val essConfigUrl = "https://ess-config.example.com" - val storeFactory = lambdaRecorder { FakeElementWellknownStore() } - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - val sut = createDefaultWellknownRetriever( - cacheStoreFactory = storeFactory, - enterpriseService = FakeEnterpriseService( - overrideWellKnownResult = { null }, - essConfigEndpointUrlResult = { essConfigUrl }, - ), - urlResolver = resolver, - ) - - sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.ESS_CONFIG) - - storeFactory.assertions().isCalledOnce().with(value("ess_config")) - resolver.assertions().isCalledOnce().with(value(essConfigUrl)) - } - - @Test - fun `get element wellknown was overridden`() = runTest { - val wellKnown = anElementWellKnown() - - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - - val sut = createDefaultWellknownRetriever( - enterpriseService = FakeEnterpriseService( - overrideWellKnownResult = { wellKnown }, - ), - urlResolver = resolver, - ) - - // The overridden value is returned - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)).isEqualTo( - WellknownRetrieverResult.Success(wellKnown) - ) - - // And the endpoint is never hit - resolver.assertions().isNeverCalled() - } - - private fun TestScope.createDefaultWellknownRetriever( - cacheStoreFactory: ElementWellknownStore.Factory = { FakeElementWellknownStore() }, - elementWellKnownParser: ElementWellKnownParser = { Result.success(expectedElementWellKnown) }, - enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }), - urlResolver: UrlContentFetcher = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }, - ) = DefaultWellknownRetriever( - elementWellknownStoreFactory = cacheStoreFactory, - enterpriseService = enterpriseService, - elementWellKnownParser = elementWellKnownParser, - urlContentFetcher = urlResolver, - coroutineScope = backgroundScope, - ) - - companion object { - private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json" - private const val WELLKNOWN_CONTENT = """{ - "registration_helper_url": "a_registration_url", - "enforce_element_pro": true, - "rageshake_url": "a_rageshake_url", - "brand_color": "#FF0000", - "notification_sound": "a_notification_sound.flac", - "idp_app_scheme": "an_app_scheme", - "content_scanner_url": "https://content-scanner.example.com", - "force_disable_e2ee": false, - "map_tiler": { - "api_key": "test_api_key", - "base_url": "https://tile-server.example.com" - } - }""" - - private val expectedElementWellKnown = ElementWellKnown( - registrationHelperUrl = "a_registration_url", - enforceElementPro = true, - rageshakeUrl = "a_rageshake_url", - brandColor = "#FF0000", - notificationSound = "a_notification_sound.flac", - identityProviderAppScheme = "an_app_scheme", - contentScannerUrl = "https://content-scanner.example.com", - customRecoveryPassphrase = null, - forceDisableE2EE = false, - mapTilerConfig = aMapTilerConfig( - apiKey = "test_api_key", - baseUrl = "https://tile-server.example.com", - ) - ) - } -} - -private class SpyUrlContentFetcher(lambda: (String) -> Result) : UrlContentFetcher { - private val spy = lambdaRecorder> { lambda(it) } - - override suspend fun getUrl(url: String): Result { - return spy(url) - } - - fun assertions() = spy.assertions() -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt deleted file mode 100644 index c5e2d2ae5b..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt +++ /dev/null @@ -1,53 +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.wellknown.test - -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.libraries.wellknown.impl.InternalElementWellKnown -import io.element.android.libraries.wellknown.impl.map -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.JsonDecodingException -import java.net.URL - -class FakeElementWellknownStore( - initialData: Map> = emptyMap(), - private val jsonProvider: JsonProvider = DefaultJsonProvider(), -) : ElementWellknownStore { - private val cachedData = mutableMapOf>() - - init { - initialData.forEach { (url, result) -> - cachedData[URL(url).host] = result - } - } - - override suspend fun get(domain: String): WellknownRetrieverResult { - return cachedData[domain] ?: WellknownRetrieverResult.NotFound - } - - @OptIn(ExperimentalSerializationApi::class) - override suspend fun update(domain: String, wellknown: String): Result { - val json = jsonProvider() - val mapped = try { - json.decodeFromString(wellknown).map() - } catch (e: JsonDecodingException) { - return Result.failure(e) - } - cachedData[domain] = WellknownRetrieverResult.Success(mapped) - return Result.success(Unit) - } - - override suspend fun delete(domain: String): Result { - cachedData.remove(domain) - return Result.success(Unit) - } -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt deleted file mode 100644 index e08969d0f9..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.wellknown.test - -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -class FakeWellknownRetriever( - private val getElementWellKnownResult: (String, EnterpriseRemoteConfigSource) -> WellknownRetrieverResult = { _, _ -> - WellknownRetrieverResult.NotFound - }, -) : WellknownRetriever { - override suspend fun getElementWellKnown(host: String, source: EnterpriseRemoteConfigSource): WellknownRetrieverResult { - return getElementWellKnownResult(host, source) - } -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetrieverFactory.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetrieverFactory.kt deleted file mode 100644 index 718649dd5d..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetrieverFactory.kt +++ /dev/null @@ -1,17 +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.wellknown.test - -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.wellknown.api.WellknownRetriever - -class FakeWellknownRetrieverFactory( - private val wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), -) : WellknownRetriever.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever = wellknownRetriever -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt deleted file mode 100644 index 36d9f7bc82..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ /dev/null @@ -1,55 +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.wellknown.test - -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.MapTilerConfig - -fun anElementWellKnown( - registrationHelperUrl: String? = null, - enforceElementPro: Boolean? = null, - rageshakeUrl: String? = null, - brandColor: String? = null, - notificationSound: String? = null, - identityProviderAppScheme: String? = null, - customRecoveryPassphrase: CustomRecoveryPassphrase? = null, - contentScannerUrl: String? = null, - forceDisableE2EE: Boolean? = null, - mapTilerConfig: MapTilerConfig? = null, -) = ElementWellKnown( - registrationHelperUrl = registrationHelperUrl, - enforceElementPro = enforceElementPro, - rageshakeUrl = rageshakeUrl, - brandColor = brandColor, - notificationSound = notificationSound, - identityProviderAppScheme = identityProviderAppScheme, - customRecoveryPassphrase = customRecoveryPassphrase, - contentScannerUrl = contentScannerUrl, - forceDisableE2EE = forceDisableE2EE, - mapTilerConfig = mapTilerConfig, -) - -fun aCustomRecoveryPassphrase( - minCharacterCount: Int = 8, -) = CustomRecoveryPassphrase( - minCharacterCount = minCharacterCount, -) - -fun aMapTilerConfig( - apiKey: String = "test_api_key", - baseUrl: String? = null, - lightStyleId: String? = null, - darkStyleId: String? = null, -) = MapTilerConfig( - apiKey = apiKey, - baseUrl = baseUrl, - lightStyleId = lightStyleId, - darkStyleId = darkStyleId, -) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 1c77bea7cf..89ad4a63fc 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 8 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 1 +private const val versionReleaseNumber = 2 object Versions { /** diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index 83236f1a5a..4160efeb78 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -11,18 +11,14 @@ package config object BuildTimeConfig { const val APPLICATION_ID = "io.element.android.x" const val APPLICATION_NAME = "Element X" - const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c" - const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c" - const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c" - val METADATA_HOST_REVERSED: String? = null + val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val URL_WEBSITE: String? = null val URL_LOGO: String? = null val URL_COPYRIGHT: String? = null val URL_ACCEPTABLE_USE: String? = null val URL_PRIVACY: String? = null val URL_POLICY: String? = null - val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val SERVICES_MAPTILER_BASE_URL: String? = null val SERVICES_MAPTILER_APIKEY: String? = null val SERVICES_MAPTILER_LIGHT_MAPID: String? = null @@ -33,7 +29,9 @@ object BuildTimeConfig { val SERVICES_SENTRY_DSN_RUST: String? = null val BUG_REPORT_URL: String? = null val BUG_REPORT_APP_NAME: String? = null - - const val PUSH_CONFIG_INCLUDE_FIREBASE = true - const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH = true + const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true + const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true + val PUSHER_APP_ID_RELEASE: String? = null + val PUSHER_APP_ID_DEBUG: String? = null + val PUSHER_APP_ID_NIGHTLY: String? = null } diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt index eb59579d79..01f4329111 100644 --- a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt @@ -9,7 +9,6 @@ package io.element.android.services.analyticsproviders.posthog import dev.zacsweers.metro.Inject -import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.core.extensions.isElement import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType @@ -17,10 +16,9 @@ import io.element.android.libraries.core.meta.BuildType @Inject class PosthogEndpointConfigProvider( private val buildMeta: BuildMeta, - private val enterpriseService: EnterpriseService, ) { fun provide(): PosthogEndpointConfig? { - return if (enterpriseService.isEnterpriseBuild) { + return if (buildMeta.isEnterpriseBuild) { PosthogEndpointConfig( host = BuildConfig.POSTHOG_HOST, apiKey = BuildConfig.POSTHOG_APIKEY, diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 202432c6d8..53cdbbf225 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -103,6 +103,7 @@ class KonsistClassNameTest { "FakeImageLoader", "FakeKeyStore", "FakeListenableFuture", + "FakeParentNode", ) .assertTrue { val interfaceName = it.name diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt new file mode 100644 index 0000000000..cee2c74960 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.tests.testutils.node + +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.architecture.NodeFactoriesBindings +import kotlin.reflect.KClass + +class FakeNodeFactoriesBindings( + private val nodeFactories: Map, AssistedNodeFactory<*>>, +) : NodeFactoriesBindings { + override fun nodeFactories() = nodeFactories +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt new file mode 100644 index 0000000000..1375043d48 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.tests.testutils.node + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import io.element.android.libraries.di.DependencyInjectionGraphOwner + +/** + * A Node creates its children using the dependency injection graph of its ancestors, so a Node under test + * needs such a parent to be able to create its children. + */ +class FakeParentNode( + override val graph: Any, +) : ParentNode( + navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), + buildContext = BuildContext.root(savedStateMap = null), +), + DependencyInjectionGraphOwner { + override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} + + @Composable + override fun View(modifier: Modifier) = Unit +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt similarity index 93% rename from tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt rename to tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt index ae8768e0b0..13fcc920c2 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.tests.testutils +package io.element.android.tests.testutils.presenter import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt new file mode 100644 index 0000000000..bd786c2484 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.tests.testutils.presenter + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter + +/** + * A presenter that cannot be used, util for testing Nodes. + */ +class NotUsedPresenter : Presenter { + @Composable + override fun present(): T = error("Not used") +} diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png new file mode 100644 index 0000000000..955af7bb9a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a856467fba0134aefcad2428493ce9169fccf90705b71d540c2eed935d9b625e +size 22639 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png new file mode 100644 index 0000000000..a4fa915963 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70386b8f6a8998f246819c6d643543babcf41ba72db86db40559a755876c7650 +size 12144 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png new file mode 100644 index 0000000000..25a66543da --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18498dbff28491dd108900453eab13d8224aa7960eefbe25993dcc82198677dd +size 35825 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png new file mode 100644 index 0000000000..be7992706c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:346946f668bf18908480bf6b97158adcd7d9099d4be111055412ac2d4b7094c9 +size 77330 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png new file mode 100644 index 0000000000..ca2936923c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc3946915978e17cf404469273a5ff174c12f53da8fc3cff53ac6df8721e0182 +size 23522 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png new file mode 100644 index 0000000000..cfef0a6883 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad20c730bb320093a5c639938430e6851244e4425b46f3ae10262d9ca96215a4 +size 21972 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png new file mode 100644 index 0000000000..ec806ea229 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb20125a38d4d9436bee64de8f8d09d4afa372a72b7e33325b8d69f5b5760cdd +size 11694 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png new file mode 100644 index 0000000000..8dc241988a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7d01b051fda7a3f6dc7736bdbd631bd2e0f0cef61dbd9c973b48cee1e69d56c +size 35624 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png new file mode 100644 index 0000000000..8e618f0070 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6637c79e684bf350204756a93679163fb17b8cf5f54c835fe81464d644edc3ff +size 75840 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png new file mode 100644 index 0000000000..2864a9ffc8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e305c212a008854c19255f91824631d55d9fb523799ecad9b50039aa8b178323 +size 23053 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png deleted file mode 100644 index c9bf9e3c1c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0990301d92d4862054ba5e54103dac359df37997242498766854d7bb13431657 -size 6068 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png deleted file mode 100644 index fb829c4873..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3022b2b813d9a3f77b0ba72706f0fb870a1c3f7b4a8efc90e611b66b6e9bdf8e -size 5926 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png deleted file mode 100644 index 23858ce575..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c7544e2566517e8692a1cccd4607bc2ab0f2767bbe9e5b39eb439d7695ad2ce5 -size 46905 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png deleted file mode 100644 index 9b505f3cf9..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b5dbf45467305e83f302fd626a2c0adcd27eac6aeea8b7a83efdb6e728d974da -size 44703 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png deleted file mode 100644 index 815986cbc3..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:41907de78c86fca804af2af7dd351db6a1ab87515e71bb906ffa2219ad4f5879 -size 45880 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png deleted file mode 100644 index f08662715a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:62581b532695d10a42432dfcb37249c28c2bd617ecb49d7f3a8224855b78ae55 -size 43600 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png index 721b1f8389..caa1dc1cbb 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fb394bf8c063e5b4457fc75c171849b1ae7f435941e0d86d09298a846595861 -size 35160 +oid sha256:da11677aad93fcf9461c62a0fb9a1f384950dc69f0666914da5785265d56a4dd +size 35877 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png index 3d04fae696..cb6f870735 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4626f30754044cee4438c44f8972f1b643342aea9c7b38f3650874da8c7e7614 -size 37303 +oid sha256:99d28d3547d50e9ae23b012521ef5f8a157aa4b3a8a98b3e138847652f8cacff +size 35761 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png index c0d9a71d1d..d9eaefae3c 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ccaed5beab96b32c7ba783136d1fe8483697de44d0ed7c7555cbef9dd3708e66 -size 38953 +oid sha256:84954d900742e981811f1471fbe175f57c927ffc4da9e801d5e5b48f073dc13a +size 39610 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png new file mode 100644 index 0000000000..aa987474a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a524b0538ad4204ea66c46c139035671f55d874c45d46476fb885c15879b93f2 +size 31806 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png index 0127885711..0b0b5f123c 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ae87930bb1123d432f7f4e2792ad1e372ec383f968ad335b4f2f069b8faa307 -size 34294 +oid sha256:bb5607ed2e0986d5035120a2e6276ff33e65a7bafa5325a0c0c22f1975b69c2b +size 34986 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png index b15dcf0142..4da08d87a3 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:787b3708bfc0bebd19cde31589e175ee0ea593c4bd1461b9593e9705947e6ffc -size 36280 +oid sha256:80c7e798346755382f913f72da9b18fc200867280e6f33adde3622a964f7d2d7 +size 34883 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png index b9046df90d..3d6c836738 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8d1a1013e5eaed74889193e4c6551ae5afa96a1d10fa3d32de3dc96a0c1ae13 -size 36582 +oid sha256:cbe35c9bb9ec509b64a2ce27afa879b8904269ae8f9afbd5ac8a91098d7fccb3 +size 38777 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png new file mode 100644 index 0000000000..bada75332c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efa31bac320466c41fbf33b462418716c1f4fb6def584b38834320d213d2c819 +size 29777 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png deleted file mode 100644 index 34cf030cba..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:09bf68ea40f2b1c109b015b8e9e9c767931ef70936a956652ac649b8e5a2561e -size 13491 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png deleted file mode 100644 index 67186daea6..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:323218a1ce6e4afaf4632fed871cbc6e17ed6686243523aeb9da0d81a7825d31 -size 13602 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png deleted file mode 100644 index c0cc08611c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:193e7af1adc71d5e1133694e234671ebb6fefe3766c9891733c3ae46a999b1cd -size 13655 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png deleted file mode 100644 index 26e32e2174..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb76a05a00c3e41c7edaa853db6d3773fddfb23c750eea4f45d4f4b1acfe5447 -size 15466 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png deleted file mode 100644 index da53968272..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a153cb8df9aadaa9cee0fef7f140e43f33851f18815674edc9a9682a7c601d2 -size 12942 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png deleted file mode 100644 index 5df3ef308b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cba9108df3dae3f9968825e13528087fdf5d8b70c03faabbf0b06e276392a7d7 -size 13048 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png deleted file mode 100644 index 91a5e6418a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:79cd361b36c8be418871a8cf6773bcc8f3626fc8d09012340f6d245ea6b0574e -size 12967 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png deleted file mode 100644 index c6b38f80b0..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9063e4d7b900c397dacd964c25ce42865e292ea2a5a41f37cb7dfabd86f9246f -size 14142 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png deleted file mode 100644 index 86e3a6a0c1..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64ca7f84a90416cac5161e379079edb3edadf1f82240214974a3216cd5d41090 -size 23418 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png deleted file mode 100644 index 583a7beb59..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd5189b1079f9011162ff7ba2c9211fbfff543db892b86ea3b57ec78495d2019 -size 50344 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png deleted file mode 100644 index fd9fce9c5c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5122ee1af4ff4ddc5e4c766f992e9833e4c1329bc634e82b0e52743326328447 -size 22757 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png deleted file mode 100644 index 1be9bd5d99..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96742102f57f2e58027e3402ea01c91ee1eab6010dc46ef8ff8ee3175264a628 -size 49259 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png new file mode 100644 index 0000000000..98a271f16e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3412f2c40e8bff9f152ad856050415a16b76376d46eda582168f75e126485676 +size 21803 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png new file mode 100644 index 0000000000..de396af500 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec927c9bd1b1e0000521d476f3141a6d2912b558ee1fddd089936afd59ac4d27 +size 20905 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png new file mode 100644 index 0000000000..3edc968290 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:346138ce103462468652d51f969514a17b096795163ce1d2be2a99adf61471f4 +size 21766 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png new file mode 100644 index 0000000000..74989de70a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1afe321e7c1f2a57d3317e952975655f89a4acae1386b425be089e03cc03a8d9 +size 22697 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png new file mode 100644 index 0000000000..7bd3c0d04f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aba767b7b1380ca1fcd49498376128b5e537da7f15b2512c6f6cf409b3a3f9df +size 19567 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png new file mode 100644 index 0000000000..b1f589b037 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05211670ecfed13426dae281438c0f51d4d98b1d33178ac1e3753bc36855b952 +size 20362 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png new file mode 100644 index 0000000000..c2ff59b834 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9417e640c72190934981c3d7f29e9b60b241c69bcab800af7908b6b5d097089a +size 19453 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png new file mode 100644 index 0000000000..7aa92967f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e38f946dfda1008370b1171f4daffb2afea93b6f8ba9d8e816b8a12124467087 +size 20365 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png new file mode 100644 index 0000000000..c5e55379a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3996b007ecb0d6ccbba7b21d35a089b5ea5f0a314fe8a09b9f88e3829272a715 +size 21290 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png new file mode 100644 index 0000000000..5270ee0b32 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d7b95deec3bb0c5b542885a8900144233434561b5488e50c1560cdb287b9a61 +size 18154 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png index f47e74ab7b..3c357233b9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5823ae9a382421609200d569799505706180b94c6923f315caa81f8767ac58f8 -size 131340 +oid sha256:95c5746de02c584536b1178614d15460bbdda633ddf44485261007618bd99f50 +size 132268 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png index 96215547bb..bae0e74d1e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1622f9765fda65985a369d6958f7a5993696dd629c0c1013c0a1647cfb7b562 -size 50109 +oid sha256:25fdfa4b65263c02e10c6ddea16a3210a84722a742a66cd2d91b8b3f61968466 +size 49924 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png index 70d41498a8..389ae383c8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a94de003f65d2fb30ab42e4ac60802707abf1358b0defed7598a3aad69992f40 -size 48136 +oid sha256:dc99c7505aa48b7ccdbae1de6daec75c12b9293172c804ef3cf027f6b46c45ea +size 47883 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png index c17cbfdb66..45f5ce2bde 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdab4bea33337547250355c29991f45c118e8c2c05abdf69b1824475feced5d6 -size 51241 +oid sha256:c446e3b322a1eb8634f2f643819672cfb626ca9e76c7e1fb5a10fc03a1ae7824 +size 51031 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png index ef9c49d6c5..ff238fd979 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f92c594febc463911c8418deeff3b94e5a8ce4171461d812a0f4f527ab0cf8e -size 49245 +oid sha256:86260ffd841034c31327df736e0e127271f875a6cf383c688b7ea8c3a9d1dbd4 +size 48943 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png index 9ba3d99527..cebea0c88b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81d8acdb1158c3e5c19b5a602e3b9afe7643f1bb77d43f51d3f5c4327478a76a -size 50282 +oid sha256:9669ba8ab19e7f7f1322e973e6f39855113b45ab5f6b26e7b5230a2fe9ec1d68 +size 50067 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png index 969e5b04bf..50cd50736a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c225ad12c5a32e2392606f9afacc6dbc37478a141d5485e4942012c6b22ad052 -size 48046 +oid sha256:f1f66076e3b06df1471f367bed57f81d15272e3600eebc51cd131794e29d5dfb +size 47767 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png index a0192f0890..c4b21b86af 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cfb1d8b7828692d5c318206770f9484822a1749b3e80a618b502811cf512d76 -size 60489 +oid sha256:ffe11074abe52ccd1c34f687f780c5185be370b2b92867ee0b686bad85f28890 +size 60210 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png index c745fa256a..839c4a88ba 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca67409e4af247b43d8594d6351419cd12894485357596aff7b26745e2df009a -size 57687 +oid sha256:e9914f3ffd70dd83013ff6059539bda348ae64fc39a2419d621f8d198f6471cc +size 57380 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png index 635e14be9e..f47016a59f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b3a91d104a5949dde83ac38b3127afa342816c631a1e29d78e2ada1bcc906bb -size 50150 +oid sha256:895ba0487ed933f4c8eddc99fe008032fe01454771ccf2987905aec357f3d9c0 +size 49972 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png index 573b6123ed..adfcb6a616 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69c281829fbca15261f196d2134f933b789ff444cd7bebc88d05cdbb7553357f -size 48012 +oid sha256:b5a435c5aa78ffda52d96d611ab869d006e6c95e8aaf4cd005d99128b4f60b1c +size 47736 diff --git a/tools/adb/force_stop.sh b/tools/adb/force_stop.sh new file mode 100755 index 0000000000..aaf9274010 --- /dev/null +++ b/tools/adb/force_stop.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +# Please see LICENSE files in the repository root for full details. + +package_name="io.element.android.x.debug" + +echo " => Force stop ${package_name}" + +adb shell am force-stop "${package_name}" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 8a7790be1e..8bd6f14c12 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -173,6 +173,7 @@ "includeRegex" : [ "screen_onboarding_.*", "screen\\.onboarding\\..*", + "screen\\.change_server\\..*", "screen\\.missing_key_backup\\..*", "screen_login_.*", "screen_server_confirmation_.*", @@ -271,6 +272,7 @@ "screen_room_retry.*", "screen_room_timeline.*", "screen\\.room\\.timeline.*", + "error\\.sending_failure\\..*", "screen\\.room_timeline.*", "screen_room_typing.*", "screen\\.image_edition\\..*", diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index fcb3669a59..7eae1a5a78 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202608010' versionName='26.08.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202608020' versionName='26.08.2' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' diff --git a/tools/release/fix-pg-map-id.py b/tools/release/fix-pg-map-id.py deleted file mode 100644 index c37fec2528..0000000000 --- a/tools/release/fix-pg-map-id.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/python3 -# encoding: utf-8 -# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman -# SPDX-License-Identifier: GPL-3.0-or-later. - -import hashlib -import os -import re -import struct -import zipfile -import zlib - -from binascii import hexlify -from typing import Any, Dict, Match, Tuple - -DEX_MAGIC = b"dex\n" -DEX_MAGIC_RE = re.compile(rb"dex\n(\d{3})\x00") - -PROF_MAGIC = b"pro\x00" -PROF_010_P = b"010\x00" - -CLASSES_DEX_RE = re.compile(r"classes\d*\.dex") -ASSET_PROF = "assets/dexopt/baseline.prof" - -PG_MAP_ID_RE = re.compile(rb'(~~R8{"backend":"dex".*?"pg-map-id":")([0-9a-f]{7})(")') - -ATTRS = ("compress_type", "create_system", "create_version", "date_time", - "external_attr", "extract_version", "flag_bits") -LEVELS = (9, 6, 4, 1) - - -class Error(RuntimeError): - pass - - -# FIXME: is there a better alternative? -class ReproducibleZipInfo(zipfile.ZipInfo): - """Reproducible ZipInfo hack.""" - - if "_compresslevel" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined] - if "compress_level" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined] - raise Error("zipfile.ZipInfo has no ._compresslevel") - - _compresslevel: int - _override: Dict[str, Any] = {} - - def __init__(self, zinfo: zipfile.ZipInfo, **override: Any) -> None: - # pylint: disable=W0231 - if override: - self._override = {**self._override, **override} - for k in self.__slots__: - if hasattr(zinfo, k): - setattr(self, k, getattr(zinfo, k)) - - def __getattribute__(self, name: str) -> Any: - if name != "_override": - try: - return self._override[name] - except KeyError: - pass - return object.__getattribute__(self, name) - - -def fix_pg_map_id(input_dir: str, output_dir: str, map_id: str) -> None: - file_data = {} - for filename in [ASSET_PROF] + sorted(os.listdir(input_dir)): - if re.fullmatch(CLASSES_DEX_RE, filename) or filename == ASSET_PROF: - print(f"reading {filename!r}...") - with open(os.path.join(input_dir, *filename.split("/")), "rb") as fh: - file_data[filename] = fh.read() - _fix_pg_map_id(file_data, map_id) - for filename, data in file_data.items(): - print(f"writing {filename!r}...") - if "/" in filename: - os.makedirs(os.path.join(output_dir, *filename.split("/")[:-1]), exist_ok=True) - with open(os.path.join(output_dir, *filename.split("/")), "wb") as fh: - fh.write(data) - - -def fix_pg_map_id_apk(input_apk: str, output_apk: str, map_id: str) -> None: - with open(input_apk, "rb") as fh_raw: - with zipfile.ZipFile(input_apk) as zf_in: - with zipfile.ZipFile(output_apk, "w") as zf_out: - file_data = {} - for info in zf_in.infolist(): - if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF: - print(f"reading {info.filename!r}...") - file_data[info.filename] = zf_in.read(info) - _fix_pg_map_id(file_data, map_id) - for info in zf_in.infolist(): - attrs = {attr: getattr(info, attr) for attr in ATTRS} - zinfo = ReproducibleZipInfo(info, **attrs) - if info.compress_type == 8: - fh_raw.seek(info.header_offset) - n, m = struct.unpack(" 0: - ccrc = zlib.crc32(fh_raw.read(min(size, 4096)), ccrc) - size -= 4096 - with zf_in.open(info) as fh_in: - comps = {lvl: zlib.compressobj(lvl, 8, -15) for lvl in LEVELS} - ccrcs = {lvl: 0 for lvl in LEVELS} - while True: - data = fh_in.read(4096) - if not data: - break - for lvl in LEVELS: - ccrcs[lvl] = zlib.crc32(comps[lvl].compress(data), ccrcs[lvl]) - for lvl in LEVELS: - if ccrc == zlib.crc32(comps[lvl].flush(), ccrcs[lvl]): - zinfo._compresslevel = lvl - break - else: - raise Error(f"Unable to determine compresslevel for {info.filename!r}") - elif info.compress_type != 0: - raise Error(f"Unsupported compress_type {info.compress_type}") - if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF: - print(f"writing {info.filename!r}...") - zf_out.writestr(zinfo, file_data[info.filename]) - else: - with zf_in.open(info) as fh_in: - with zf_out.open(zinfo, "w") as fh_out: - while True: - data = fh_in.read(4096) - if not data: - break - fh_out.write(data) - - -def _fix_pg_map_id(file_data: Dict[str, bytes], map_id: str) -> None: - crcs = {} - for filename in file_data: - if re.fullmatch(CLASSES_DEX_RE, filename): - print(f"fixing {filename!r}...") - data = _fix_dex_id_checksum(file_data[filename], map_id.encode()) - file_data[filename] = data - crcs[filename] = zlib.crc32(data) - if ASSET_PROF in file_data: - print(f"fixing {ASSET_PROF!r}...") - file_data[ASSET_PROF] = _fix_prof_checksum(file_data[ASSET_PROF], crcs) - - -def _fix_dex_id_checksum(data: bytes, map_id: bytes) -> bytes: - def repl(m: Match[bytes]) -> bytes: - print(f"fixing pg-map-id: {m.group(2)!r} -> {map_id!r}") - return m.group(1) + map_id + m.group(3) - - magic = data[:8] - if magic[:4] != DEX_MAGIC or not DEX_MAGIC_RE.fullmatch(magic): - raise Error(f"Unsupported magic {magic!r}") - print(f"dex version={int(magic[4:7]):03d}") - checksum, signature = struct.unpack(" {hexlify(fixed_sig).decode()}") - fixed_data = fixed_sig + fixed_data - fixed_checksum = zlib.adler32(fixed_data) - print(f"fixing checksum: 0x{checksum:x} -> 0x{fixed_checksum:x}") - return magic + int.to_bytes(fixed_checksum, 4, "little") + fixed_data - - -def _fix_prof_checksum(data: bytes, crcs: Dict[str, int]) -> bytes: - magic, data = _split(data, 4) - version, data = _split(data, 4) - if magic == PROF_MAGIC: - if version == PROF_010_P: - print("prof version=010 P") - return PROF_MAGIC + PROF_010_P + _fix_prof_010_p_checksum(data, crcs) - else: - raise Error(f"Unsupported prof version {version!r}") - else: - raise Error(f"Unsupported magic {magic!r}") - - -def _fix_prof_010_p_checksum(data: bytes, crcs: Dict[str, int]) -> bytes: - num_dex_files, uncompressed_data_size, compressed_data_size, data = _unpack(" 0x{fixed_checksum:x}") - dex_data_headers.append(struct.pack( - " Any: - assert all(c in " Tuple[bytes, bytes]: - return data[:size], data[size:] - - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(prog="fix-pg-map-id.py") - parser.add_argument("input_dir_or_apk", metavar="INPUT_DIR_OR_APK") - parser.add_argument("output_dir_or_apk", metavar="OUTPUT_DIR_OR_APK") - parser.add_argument("pg_map_id", metavar="PG_MAP_ID") - args = parser.parse_args() - if os.path.isdir(args.input_dir_or_apk): - fix_pg_map_id(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id) - else: - fix_pg_map_id_apk(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id) - -# vim: set tw=80 sw=4 sts=4 et fdm=marker : diff --git a/tools/release/inplace-fix.py b/tools/release/inplace-fix.py deleted file mode 100644 index a791e69140..0000000000 --- a/tools/release/inplace-fix.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/python3 -# encoding: utf-8 -# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman -# SPDX-License-Identifier: GPL-3.0-or-later. - -import argparse -import os -import shutil -import subprocess -import sys -import tempfile - -from typing import Optional, Tuple - -COMMANDS = ( - "fix-compresslevel", - "fix-files", - "fix-newlines", - "fix-pg-map-id", - "rm-files", - "sort-apk", - "sort-baseline", -) - -BUILD_TOOLS_WITH_BROKEN_ZIPALIGN = ("31.0.0", "32.0.0") -BUILD_TOOLS_WITH_PAGE_SIZE_FROM = "35.0.0-rc1" -SDK_ENV = ("ANDROID_HOME", "ANDROID_SDK", "ANDROID_SDK_ROOT") - - -def _zipalign_cmd(page_align: bool, page_size: Optional[int]) -> Tuple[str, ...]: - if page_align: - if page_size is not None: - return ("zipalign", "-P", str(page_size), "4") - return ("zipalign", "-p", "4") - return ("zipalign", "4") - - -ZIPALIGN = _zipalign_cmd(page_align=False, page_size=None) -ZIPALIGN_P = _zipalign_cmd(page_align=True, page_size=None) - - -class Error(RuntimeError): - pass - - -def inplace_fix(command: str, input_file: str, *args: str, - zipalign: bool = False, page_align: bool = False, - page_size: Optional[int] = None, internal: bool = False) -> None: - if command not in COMMANDS: - raise Error(f"Unknown command {command}") - exe, script = _script_cmd(command) - ext = os.path.splitext(input_file)[1] - with tempfile.TemporaryDirectory() as tdir: - fixed = os.path.join(tdir, "fixed" + ext) - run_command(exe, script, input_file, fixed, *args, trim=2) - if zipalign: - aligned = os.path.join(tdir, "aligned" + ext) - zac = zipalign_cmd(page_align=page_align, page_size=page_size, internal=internal) - run_command(*zac, fixed, aligned, trim=2) - print(f"[MOVE] {aligned} to {input_file}") - shutil.move(aligned, input_file) - else: - print(f"[MOVE] {fixed} to {input_file}") - shutil.move(fixed, input_file) - - -def zipalign_cmd(page_align: bool = False, page_size: Optional[int] = None, - internal: bool = False) -> Tuple[str, ...]: - """ - Find zipalign command using $PATH or $ANDROID_HOME etc. - - >>> zipalign_cmd() - ('zipalign', '4') - >>> zipalign_cmd(page_align=True) - ('zipalign', '-p', '4') - >>> zipalign_cmd(page_align=True, page_size=16) - ('zipalign', '-P', '16', '4') - >>> cmd = zipalign_cmd(page_align=True, page_size=16, internal=True) - >>> [x.split("/")[-1] for x in cmd] - ['python3', 'zipalign.py', '-P', '16', '4'] - >>> os.environ["PATH"] = "" - >>> for k in SDK_ENV: - ... os.environ[k] = "" - >>> cmd = zipalign_cmd() - >>> [x.split("/")[-1] for x in cmd] - ['python3', 'zipalign.py', '4'] - >>> os.environ["ANDROID_HOME"] = "test/fake-sdk" - >>> zipalign_cmd() - [SKIP BROKEN] 31.0.0 - [FOUND] test/fake-sdk/build-tools/30.0.3/zipalign - ('test/fake-sdk/build-tools/30.0.3/zipalign', '4') - >>> cmd = zipalign_cmd(page_align=True, page_size=16) - [SKIP TOO OLD] 31.0.0 - [SKIP TOO OLD] 30.0.3 - [SKIP TOO OLD] 26.0.2 - >>> [x.split("/")[-1] for x in cmd] - ['python3', 'zipalign.py', '-P', '16', '4'] - >>> os.environ["ANDROID_HOME"] = "test/fake-sdk-2" - >>> zipalign_cmd(page_align=True, page_size=16) - [FOUND] test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign - ('test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign', '-P', '16', '4') - - """ - cmd, *args = _zipalign_cmd(page_align, page_size) - if not internal: - if shutil.which(cmd): - return (cmd, *args) - for k in SDK_ENV: - if home := os.environ.get(k): - tools = os.path.join(home, "build-tools") - if os.path.exists(tools): - for vsn in sorted(os.listdir(tools), key=_vsn, reverse=True): - if page_size and _vsn(vsn) < _vsn(BUILD_TOOLS_WITH_PAGE_SIZE_FROM): - print(f"[SKIP TOO OLD] {vsn}") - continue - for s in BUILD_TOOLS_WITH_BROKEN_ZIPALIGN: - if vsn.startswith(s): - print(f"[SKIP BROKEN] {vsn}") - break - else: - c = os.path.join(tools, vsn, cmd) - if shutil.which(c): - print(f"[FOUND] {c}") - return (c, *args) - return (*_script_cmd(cmd), *args) - - -def _vsn(v: str) -> Tuple[int, ...]: - """ - >>> vs = "31.0.0 32.1.0-rc1 34.0.0-rc3 34.0.0 35.0.0-rc1".split() - >>> for v in sorted(vs, key=_vsn, reverse=True): - ... (_vsn(v), v) - ((35, 0, 0, 0, 1), '35.0.0-rc1') - ((34, 0, 0, 1, 0), '34.0.0') - ((34, 0, 0, 0, 3), '34.0.0-rc3') - ((32, 1, 0, 0, 1), '32.1.0-rc1') - ((31, 0, 0, 1, 0), '31.0.0') - """ - if "-rc" in v: - v = v.replace("-rc", ".0.", 1) - else: - v = v + ".1.0" - return tuple(int(x) if x.isdigit() else -1 for x in v.split(".")) - - -def _script_cmd(command: str) -> Tuple[str, str]: - script_dir = os.path.dirname(__file__) - for cmd in (command, command.replace("-", "_")): - script = os.path.join(script_dir, cmd + ".py") - if os.path.exists(script): - break - else: - raise Error(f"Script for {command} not found") - exe = sys.executable or "python3" - return exe, script - - -def run_command(*args: str, trim: int = 1) -> None: - targs = tuple(os.path.basename(a) for a in args[:trim]) + args[trim:] - print(f"[RUN] {' '.join(targs)}") - try: - subprocess.run(args, check=True) - except subprocess.CalledProcessError as e: - raise Error(f"{args[0]} command failed") from e - except FileNotFoundError as e: - raise Error(f"{args[0]} command not found") from e - - -def main() -> None: - prog = os.path.basename(sys.argv[0]) - usage = (f"{prog} [-h] [--zipalign] [--page-align] [--page-size N] [--internal]\n" - f"{len('usage: ' + prog) * ' '} COMMAND INPUT_FILE [...]") - epilog = f"Commands: {', '.join(COMMANDS)}." - parser = argparse.ArgumentParser(usage=usage, epilog=epilog) - parser.add_argument("--zipalign", action="store_true", - help="run zipalign after COMMAND") - parser.add_argument("--page-align", action="store_true", - help="run zipalign w/ -p option (implies --zipalign)") - parser.add_argument("--page-size", metavar="N", type=int, - help="run zipalign w/ -P N option (implies --page-align)") - parser.add_argument("--internal", action="store_true", - help="use zipalign.py instead of searching $PATH/$ANDROID_HOME/etc.") - parser.add_argument("command", metavar="COMMAND") - parser.add_argument("input_file", metavar="INPUT_FILE") - args, rest = parser.parse_known_args() - try: - inplace_fix(args.command, args.input_file, *rest, - zipalign=bool(args.zipalign or args.page_align or args.page_size), - page_align=bool(args.page_align or args.page_size), - page_size=args.page_size, internal=args.internal) - except Error as e: - print(f"Error: {e}.", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() - -# vim: set tw=80 sw=4 sts=4 et fdm=marker : diff --git a/tools/release/release.sh b/tools/release/release.sh index 83ccc6d258..392074208a 100755 --- a/tools/release/release.sh +++ b/tools/release/release.sh @@ -225,15 +225,6 @@ printf "Unzipping the F-Droid artifact...\n" fdroidTargetPath="${targetPath}/fdroid" unzip "${targetPath}"/elementx-app-fdroid-apks-unsigned.zip -d "${fdroidTargetPath}" -printf "\n================================================================================\n" -printf "Patching the FDroid APKs using inplace-fix.py...\n" - -inplaceFixScript="./tools/release/inplace-fix.py" -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk '0000000' -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk '0000000' -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86-release.apk '0000000' -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk '0000000' - printf "\n================================================================================\n" printf "Signing the FDroid APKs...\n"