Merge develop into the PreviewParam rename

The 174 commits develop gained since this branch was cut touch preview data
in three ways, and each is resolved so the flipped konsist rule still holds
across the merged tree.

Two screens the branch had renamed were deleted upstream by "Remove the
account provider search screen and homeserver resolver": the
changeaccountprovider and searchaccountprovider packages. Their deletion
wins, so ChangeAccountProviderStatePreviewParam, SearchAccountProviderStatePreviewParam
and the two views that used them go with them rather than coming back
under the new name.

Two preview classes develop added still carried the old suffix and are
renamed here, along with the `@PreviewParameter` reference in each view:
GlobalSearchStateProvider -> GlobalSearchStatePreviewParam and
SendFailureDialogStateProvider -> SendFailureDialogStatePreviewParam.

Everything else merged textually. No production class extending
PreviewParameterProvider is left with a `Provider` name, and no reference
to any of the 194 renamed names survives outside the CHANGES.md entry that
records a past pull request title.
This commit is contained in:
hayaksi1
2026-08-24 10:55:40 +03:00
397 changed files with 6635 additions and 4221 deletions
@@ -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
}
@@ -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)
}
}
}
@@ -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()
@@ -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 = {
@@ -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)
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.utils
interface PipController {
fun setupPipCallbacks()
suspend fun canEnterPip(): Boolean
fun enterPip()
fun exitPip()
@@ -250,7 +250,7 @@ class WebViewAudioManager(
hasRegisteredCallbacks = true
}
}
},
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
@@ -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)
}
}
@@ -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()
@@ -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)
}
@@ -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
@@ -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
}
@@ -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)
}
@@ -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<String>
fun homeserverAllowList(): List<String>
/**
* 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)
}
}
@@ -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
}
@@ -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()
}
@@ -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<MapTilerConfig?>
}
@@ -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.api.remoteconfig
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,
/** The style ID to use for light mode. This is optional and can be null. */
val lightStyleId: String?,
/** The style ID to use for light mode. This is optional and can be null. */
val darkStyleId: String?,
/** The base URL to use for MapTiler. This is optional and can be null. */
val baseUrl: String?,
)
@@ -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
@@ -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<RemoteEnterpriseConfig>
/**
* 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
}
}
@@ -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.features.enterprise.api.remoteconfig
sealed interface RemoteEnterpriseConfigResult<out T> {
/**
* Well-known data has been successfully retrieved.
*/
data class Success<out T>(val data: T) : RemoteEnterpriseConfigResult<T>
/**
* Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched.
*/
data class Outdated<out T>(val data: T) : RemoteEnterpriseConfigResult<T>
/**
* Well-known data is not found (file does not exist server side, we got a 404).
*/
data object NotFound : RemoteEnterpriseConfigResult<Nothing>
/**
* Any other error.
*/
data class Error(val exception: Exception) : RemoteEnterpriseConfigResult<Nothing>
fun dataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> data
is Error -> null
is NotFound -> null
}
fun upToDateDataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> null
is Error -> null
is NotFound -> null
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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<String> = emptyList()
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun isElementProEnforced(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
}
@@ -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
}
@@ -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<MapTilerConfig?> = Result.success(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<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -1,29 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
}
@@ -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
@@ -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<String> = { 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<String> {
override fun homeserverAllowList(): List<String> {
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)
}
}
@@ -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<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -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
}
@@ -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,
)
@@ -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()
@@ -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 -> {
@@ -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 = {},
@@ -15,6 +15,8 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.roomlist.RoomListPresenter
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.GlobalSearchPresenter
import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchPresenter
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
@@ -36,4 +38,7 @@ interface RoomListModule {
@Binds
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter<SpaceFiltersState>
@Binds
fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter<GlobalSearchState>
}
@@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.into
import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
@@ -85,6 +86,7 @@ class RoomListPresenter(
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val globalSearchPresenter: Presenter<GlobalSearchState>,
private val featureFlagService: FeatureFlagService,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@@ -97,6 +99,7 @@ class RoomListPresenter(
val searchState = searchPresenter.present()
val spaceFiltersState = spaceFiltersPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
val globalSearchState = globalSearchPresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -177,6 +180,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
@@ -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,
@@ -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<RoomListState> {
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(
searchState = aRoomListSearchState(isSearchActive = true, query = "Test"),
globalSearchState = aGlobalSearchState(isEnabled = false),
),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
@@ -53,6 +58,7 @@ internal fun aRoomListState(
declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
globalSearchState: GlobalSearchState = aGlobalSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
@@ -66,6 +72,7 @@ internal fun aRoomListState(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
@@ -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
}
@@ -0,0 +1,301 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.search.MessageSearch
import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
import io.element.android.libraries.matrix.api.search.MessageSearchResult
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.jvm.optionals.getOrElse
import kotlin.time.Duration.Companion.milliseconds
@Inject
class GlobalSearchPresenter(
private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory,
private val messageSearchService: MessageSearchService,
private val featureFlagService: FeatureFlagService,
private val latestEventFormatter: RoomLatestEventFormatter,
private val dateFormatter: DateFormatter,
private val fileSizeFormatter: FileSizeFormatter,
private val permalinkParser: PermalinkParser,
private val coroutineDispatchers: CoroutineDispatchers,
private val matrixClient: MatrixClient,
) : Presenter<GlobalSearchState> {
@Composable
override fun present(): GlobalSearchState {
val coroutineScope = rememberCoroutineScope()
val isEnabled by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
}
val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var searchResults: AsyncData<GlobalSearchResults> by remember { mutableStateOf(AsyncData.Uninitialized) }
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
LaunchedEffect(queryState.text) {
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive
delay(200.milliseconds)
searchResults = if (queryState.text.isNotEmpty()) {
AsyncData.Loading(prevData = searchResults.dataOrNull())
} else {
AsyncData.Uninitialized
}
launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) }
launch {
currentMessageSearch.setQuery(queryState.text.toString())
.onFailure { Timber.e(it, "Could not set query for message search") }
}
}
LaunchedEffect(currentTarget) {
if (queryState.text.isNotEmpty()) {
searchResults = AsyncData.Loading()
}
when (currentTarget) {
GlobalSearchTarget.ROOMS -> {
roomListSearchDataSource.roomSummaries.collectLatest { results ->
Timber.d("Room list search found ${results.size} items")
// The room list data source always returns some values, even if the query is empty,
// so we only want to update the search results if the query is not empty
searchResults = if (queryState.text.isNotEmpty()) {
AsyncData.Success(GlobalSearchResults.RoomListResults(results = results))
} else {
AsyncData.Uninitialized
}
}
}
GlobalSearchTarget.MESSAGES -> {
Timber.d("Message search found ${currentMessageSearch.results.value.size} items")
combine(
currentMessageSearch.results,
currentMessageSearch.paginationState,
) { results, paginationState ->
Pair(results, paginationState)
}.collectLatest { (results, paginationState) ->
Timber.d("Message search found ${results.size} items, pagination state: $paginationState")
// Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each
// result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
val mappedResults = results.map { result ->
async(coroutineDispatchers.computation) {
val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
val formattedTimestamp = dateFormatter.format(
timestamp = result.timestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true
)
if (!result.content.isMediaContent()) {
mapMessageContent(result, roomInfo, formattedTimestamp)
} else {
mapMediaContent(result, roomInfo, formattedTimestamp)
}
}
}
.awaitAll()
.mapNotNull { it }
searchResults = when {
queryState.text.isEmpty() -> AsyncData.Uninitialized
mappedResults.isNotEmpty() -> {
AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList()))
}
paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> {
AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf()))
}
paginationState is MessageSearchPaginationState.Loading -> {
AsyncData.Loading(prevData = searchResults.dataOrNull())
}
else -> {
AsyncData.Uninitialized
}
}
}
}
}
}
fun handleEvent(event: GlobalSearchEvent) {
when (event) {
GlobalSearchEvent.ClearQuery -> {
queryState.clearText()
}
GlobalSearchEvent.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
queryState.clearText()
}
is GlobalSearchEvent.UpdateVisibleRange -> {
when (currentTarget) {
GlobalSearchTarget.ROOMS -> coroutineScope.launch {
// TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe
// to the room info for each room in the search results.
roomListSearchDataSource.updateVisibleRange(event.range)
}
GlobalSearchTarget.MESSAGES -> coroutineScope.launch {
val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0
val currentPaginationState = currentMessageSearch.paginationState.value
val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached
if (event.range.last >= currentCount - 10 && canPaginate) {
currentMessageSearch.paginate()
}
}
}
}
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
}
}
return GlobalSearchState(
isEnabled = isEnabled,
isSearchActive = isSearchActive,
queryState = queryState,
currentTarget = currentTarget,
results = searchResults,
eventSink = ::handleEvent,
)
}
private fun mapMessageContent(
result: MessageSearchResult,
roomInfo: RoomInfo,
formattedTimestamp: String,
): MessageSearchResultItem.Message? {
val body = latestEventFormatter.format(
latestEvent = LatestEventValue.Remote(
timestamp = result.timestamp,
content = result.content,
senderId = result.senderId,
senderProfile = result.senderProfile,
isOwn = matrixClient.isMe(result.senderId),
),
isDmRoom = false,
)
return MessageSearchResultItem.Message(
messageSearchResult = result,
body = body?.toString() ?: "",
formattedTimestamp = formattedTimestamp,
roomInfo = roomInfo,
)
}
private fun mapMediaContent(
result: MessageSearchResult,
roomInfo: RoomInfo,
formattedTimestamp: String,
): MessageSearchResultItem.Media? {
val messageType = when (val content = result.content) {
is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
else -> return null
}
val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename)
val thumbnailType = when (messageType) {
is ImageMessageType -> AttachmentThumbnailType.Image
is VideoMessageType -> AttachmentThumbnailType.Video
is AudioMessageType -> AttachmentThumbnailType.Audio
is VoiceMessageType -> AttachmentThumbnailType.Voice
is FileMessageType -> AttachmentThumbnailType.File
is StickerMessageType -> AttachmentThumbnailType.Image
}
val thumbnailSource = when (messageType) {
is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source
is VideoMessageType -> messageType.info?.thumbnailSource
is AudioMessageType -> null
is VoiceMessageType -> null
is FileMessageType -> null
is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source
}
val blurhash = when (messageType) {
is ImageMessageType -> messageType.info?.blurhash
is VideoMessageType -> messageType.info?.blurhash
is AudioMessageType -> null
is VoiceMessageType -> null
is FileMessageType -> null
is StickerMessageType -> messageType.info?.blurhash
}
val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase()
val formattedSize = when (messageType) {
is ImageMessageType -> messageType.info?.size
is VideoMessageType -> messageType.info?.size
is AudioMessageType -> messageType.info?.size
is VoiceMessageType -> messageType.info?.size
is FileMessageType -> messageType.info?.size
is StickerMessageType -> messageType.info?.size
}
?.let(fileSizeFormatter::format)
val mediaContent = MediaSearchResultContent(
filename = messageType.filename,
extension = extension,
caption = caption,
formattedSize = formattedSize,
thumbnailSource = thumbnailSource,
thumbnailType = thumbnailType,
blurhash = blurhash
)
return MessageSearchResultItem.Media(
messageSearchResult = result,
mediaContent = mediaContent,
formattedTimestamp = formattedTimestamp,
roomInfo = roomInfo,
)
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.search.MessageSearchResult
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class GlobalSearchState(
val isEnabled: Boolean,
val isSearchActive: Boolean,
val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>,
val eventSink: (GlobalSearchEvent) -> Unit
)
@Immutable
enum class GlobalSearchTarget {
ROOMS,
MESSAGES,
}
@Immutable
sealed interface GlobalSearchResults {
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : GlobalSearchResults
fun isEmpty(): Boolean = when (this) {
is RoomListResults -> results.isEmpty()
is MessageSearchResults -> results.isEmpty()
}
}
sealed interface MessageSearchResultItem {
val roomId: RoomId
val eventId: EventId
val senderId: UserId
val senderName: String
val roomInfo: RoomInfo
val formattedTimestamp: String
data class Message(
val messageSearchResult: MessageSearchResult,
val body: String,
override val roomInfo: RoomInfo,
override val formattedTimestamp: String,
) : MessageSearchResultItem {
override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId
override val senderId: UserId = messageSearchResult.senderId
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
}
data class Media(
val messageSearchResult: MessageSearchResult,
val mediaContent: MediaSearchResultContent,
override val roomInfo: RoomInfo,
override val formattedTimestamp: String,
) : MessageSearchResultItem {
override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId
override val senderId: UserId = messageSearchResult.senderId
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
}
}
data class MediaSearchResultContent(
val filename: String,
val extension: String?,
val caption: String?,
val formattedSize: String?,
val thumbnailSource: MediaSource?,
val thumbnailType: AttachmentThumbnailType,
val blurhash: String?,
)
@@ -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<GlobalSearchState> {
override val values: Sequence<GlobalSearchState>
get() = sequenceOf(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
)
}
fun aRoomInfo(
id: RoomId = RoomId("!roomId:server.org"),
name: String? = "A room",
rawName: String? = "A room raw name",
topic: String? = "A room topic",
avatarUrl: String? = "https://example.com/avatar.png",
isPublic: Boolean = true,
isDirect: Boolean = false,
isEncrypted: Boolean = false,
joinRule: JoinRule? = JoinRule.Public,
isSpace: Boolean = false,
successorRoom: SuccessorRoom? = null,
isFavorite: Boolean = false,
canonicalAlias: RoomAlias? = null,
alternativeAliases: List<RoomAlias> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
inviter: RoomMember? = null,
activeMembersCount: Long = 2,
invitedMembersCount: Long = 1,
joinedMembersCount: Long = 1,
highlightCount: Long = 0,
notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
roomPowerLevels: RoomPowerLevels? = RoomPowerLevels(
values = RoomPowerLevelsValues(
ban = 0,
invite = 0,
kick = 0,
eventsDefault = 0,
stateDefault = 0,
redactEvents = 0,
roomName = 0,
roomAvatar = 0,
roomTopic = 0,
spaceChild = 0,
beacon = 0,
beaconInfo = 0
),
users = persistentMapOf(),
),
activeRoomCallParticipants: List<UserId> = emptyList(),
heroes: List<MatrixUser> = emptyList(),
pinnedEventIds: List<EventId> = emptyList(),
roomCreators: List<UserId> = emptyList(),
isMarkedUnread: Boolean = false,
numUnreadMessages: Long = 0,
numUnreadNotifications: Long = 0,
numUnreadMentions: Long = 0,
historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
roomVersion: String? = "11",
privilegedCreatorRole: Boolean = false,
isLowPriority: Boolean = false,
activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None,
isDm: Boolean = false,
fullyReadEventId: EventId? = null,
) = RoomInfo(
id = id,
name = name,
rawName = rawName,
topic = topic,
avatarUrl = avatarUrl,
isPublic = isPublic,
isDirect = isDirect,
isEncrypted = isEncrypted,
joinRule = joinRule,
isSpace = isSpace,
successorRoom = successorRoom,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership,
inviter = inviter,
activeMembersCount = activeMembersCount,
invitedMembersCount = invitedMembersCount,
joinedMembersCount = joinedMembersCount,
highlightCount = highlightCount,
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
roomPowerLevels = roomPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
heroes = heroes.toImmutableList(),
pinnedEventIds = pinnedEventIds.toImmutableList(),
creators = roomCreators.toImmutableList(),
isMarkedUnread = isMarkedUnread,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions,
historyVisibility = historyVisibility,
roomVersion = roomVersion,
privilegedCreatorRole = privilegedCreatorRole,
isLowPriority = isLowPriority,
activeCallIntentConsensus = activeCallIntentConsensus,
isDm = isDm,
fullyReadEventId = fullyReadEventId,
)
fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
threadInfo: EventThreadInfo? = null,
messageType: MessageType = TextMessageType(
body = body,
formatted = null
)
) = MessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
threadInfo = threadInfo,
type = messageType
)
internal fun aMessageSearchResult(
roomId: RoomId = RoomId("!roomId:server.org"),
eventId: EventId = EventId("\$eventId:server.org"),
senderId: UserId = UserId("@user:server.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
content: MessageContent = aMessageContent(),
timestamp: Long = 0L,
) = MessageSearchResult(
roomId = roomId,
eventId = eventId,
senderId = senderId,
senderProfile = senderProfile,
content = content,
timestamp = timestamp,
)
internal fun aGlobalSearchState(
isEnabled: Boolean = true,
isSearchActive: Boolean = false,
queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState(
isEnabled = isEnabled,
isSearchActive = isSearchActive,
queryState = queryState,
currentTarget = currentTarget,
results = results,
eventSink = eventSink
)
@@ -0,0 +1,564 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun GlobalSearchView(
state: GlobalSearchState,
onSelectSearchResult: (RoomId, EventId?) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = state.isSearchActive) {
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
}
AnimatedVisibility(
visible = state.isSearchActive,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(modifier = modifier) {
GlobalSearchContent(
state = state,
onBackButtonClick = {
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
},
onSelectSearchResult = onSelectSearchResult,
)
}
}
}
@Composable
private fun GlobalSearchContent(
state: GlobalSearchState,
onBackButtonClick: () -> Unit,
onSelectSearchResult: (RoomId, EventId?) -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
val drawBottomLineModifier = Modifier.drawBehind {
drawLine(
color = borderColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.value
)
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackButtonClick) },
title = {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding ->
Column(
modifier = Modifier.padding(padding),
) {
SingleChoiceSegmentedButtonRow(
modifier = drawBottomLineModifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = lazyColumnContentPadding,
state = lazyListState,
) {
val results = state.results.dataOrNull()
when {
state.results.isUninitialized() -> startSearching()
state.results.isLoading() -> loading()
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
results is GlobalSearchResults.RoomListResults -> roomListResults(
results = results.results,
onRoomClick = { roomId -> onSelectSearchResult(roomId, null) },
)
results is GlobalSearchResults.MessageSearchResults -> messageSearchResults(
results = results.results,
onSearchResultSelected = onSelectSearchResult,
)
}
}
}
}
}
private fun LazyListScope.startSearching() {
item {
IconTitleSubtitleMolecule(
modifier = Modifier
.fillMaxWidth()
.padding(40.dp),
title = stringResource(R.string.start_searching_title),
subTitle = stringResource(R.string.start_searching_subtitle),
iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
)
}
}
private fun LazyListScope.loading() {
item {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
private fun LazyListScope.emptySearchResults(query: String) {
item {
IconTitleSubtitleMolecule(
modifier = Modifier
.fillMaxWidth()
.padding(40.dp),
title = stringResource(R.string.search_no_results_title),
subTitle = stringResource(R.string.search_no_results_subtitle, query),
iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
)
}
}
private fun LazyListScope.roomListResults(
results: ImmutableList<RoomListRoomSummary>,
onRoomClick: (RoomId) -> Unit,
) {
itemsIndexed(
items = results,
key = { _, roomSummary -> roomSummary.roomId },
) { index, result ->
Row(
modifier = Modifier
.niceClickable(onClick = { onRoomClick(result.roomId) })
.fillMaxWidth()
.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem),
avatarType = AvatarType.Room(
heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(),
isTombstoned = result.isTombstoned,
),
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = result.name ?: result.roomId.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
if (result.isDm) {
val userId = result.heroes.firstOrNull()?.id
userId?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else {
result.canonicalAlias?.let {
Text(
text = it.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
}
private fun LazyListScope.messageSearchResults(
results: ImmutableList<MessageSearchResultItem>,
onSearchResultSelected: (RoomId, EventId?) -> Unit,
) {
if (results.isNotEmpty()) {
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = stringResource(R.string.search_from_your_messages_header),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
)
}
}
itemsIndexed(
items = results,
contentType = { _, messageSearchResult -> messageSearchResult::class.java },
key = { _, messageSearchResult -> messageSearchResult.eventId },
) { index, result ->
val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
when (result) {
is MessageSearchResultItem.Message -> TextMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
avatarType = AvatarType.Room(
heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
isTombstoned = result.roomInfo.successorRoom != null
),
roomName = result.roomInfo.name ?: result.roomInfo.id.value,
body = result.body,
formattedTimestamp = result.formattedTimestamp,
)
is MessageSearchResultItem.Media -> {
MediaMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
avatarType = AvatarType.Room(
heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
isTombstoned = result.roomInfo.successorRoom != null
),
roomName = result.roomInfo.name ?: result.roomInfo.id.value,
content = result.mediaContent,
formattedTimestamp = result.formattedTimestamp,
)
}
}
}
}
@Composable
private fun TextMessageSearchResultItemView(
avatarData: AvatarData,
avatarType: AvatarType,
roomName: String,
body: String,
formattedTimestamp: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = avatarData,
avatarType = avatarType,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
modifier = Modifier.weight(1f),
text = roomName,
maxLines = 1,
style = ElementTheme.typography.fontBodyLgMedium,
)
Text(
text = formattedTimestamp,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
Text(
text = body,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
@Composable
private fun MediaMessageSearchResultItemView(
avatarData: AvatarData,
avatarType: AvatarType,
roomName: String,
content: MediaSearchResultContent,
formattedTimestamp: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = avatarData,
avatarType = avatarType,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
modifier = Modifier.weight(1f),
text = roomName,
maxLines = 1,
style = ElementTheme.typography.fontBodyLgMedium,
)
Text(
text = formattedTimestamp,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
content.caption?.let {
Text(
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
Row(
modifier = Modifier
.fillMaxWidth(1f)
.background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AttachmentThumbnail(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp)),
info = AttachmentThumbnailInfo(
type = content.thumbnailType,
thumbnailSource = content.thumbnailSource,
textContent = null,
blurHash = content.blurhash,
),
thumbnailSize = 36.dp.roundToPx().toLong(),
backgroundColor = ElementTheme.colors.bgCanvasDefault,
)
Column {
Text(
text = content.filename,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
content.extension?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
content.formattedSize?.let {
Text(
text = "($it)",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
}
}
private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier {
if (isLastItem) return this
return this.drawBehind {
val strokeWidthPx = 1.dp.toPx()
drawLine(
color = color,
start = Offset(0f, size.height - strokeWidthPx),
end = Offset(size.width, size.height - strokeWidthPx),
strokeWidth = strokeWidthPx,
)
}
}
@PreviewsDayNight
@Composable
internal fun GlobalSearchViewPreview(
@PreviewParameter(GlobalSearchStatePreviewParam::class) state: GlobalSearchState,
) {
ElementPreview {
GlobalSearchView(
state = state,
onSelectSearchResult = { _, _ -> },
)
}
}
@@ -46,6 +46,8 @@ class RoomListSearchDataSource(
coroutineScope = coroutineScope
)
val loadingState = roomList.loadingState
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.summaries
.map { roomSummaries ->
roomSummaries
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="start_searching_title">Start searching…</string>
<string name="start_searching_subtitle">Search for chats and messages</string>
<string name="search_section_chats">Chats</string>
<string name="search_from_your_messages_header">From your messages</string>
<string name="search_section_messages">Messages</string>
<string name="search_no_results_title">No results</string>
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
</resources>
@@ -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()
@@ -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,
@@ -277,7 +277,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
onRoomClick = onRoomClick,
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
@@ -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,
)
}
@@ -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()
}
}
@@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@@ -31,6 +33,7 @@ class DeclineAndBlockNode(
data class Inputs(val inviteData: InviteData) : NodeInputs
private val inviteData = inputs<Inputs>().inviteData
private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inviteData)
@Composable
@@ -39,6 +42,7 @@ class DeclineAndBlockNode(
DeclineAndBlockView(
state = state,
onBackClick = ::navigateUp,
onDeclineSuccess = callback::onDeclineSuccess,
modifier = modifier
)
}
@@ -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 = {},
)
}
@@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
callback: DeclineInviteAndBlockEntryPoint.Callback,
): Node {
val inputs = DeclineAndBlockNode.Inputs(inviteData)
return parentNode.createNode<DeclineAndBlockNode>(buildContext, plugins = listOf(inputs))
return parentNode.createNode<DeclineAndBlockNode>(buildContext, plugins = listOf(inputs, callback))
}
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.invite.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() {
}
}
@Test
fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false)
ensureCalledOnce {
setDeclineAndBlockView(
aDeclineAndBlockState(
declineAction = AsyncAction.Success(Unit),
eventSink = eventsRecorder,
),
onDeclineSuccess = it,
)
}
}
@Test
fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>()
@@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.setDeclineAndBlockView(
state: DeclineAndBlockState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onDeclineSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
DeclineAndBlockView(
state = state,
onBackClick = onBackClick,
onDeclineSuccess = onDeclineSuccess,
)
}
}
@@ -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)
}
}
@@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
callback: DeclineInviteAndBlockEntryPoint.Callback,
): Node {
lambdaError()
}
@@ -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
)
}
@@ -60,7 +60,13 @@ class LeaveRoomPresenter(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = 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<AsyncAction<Unit>>,
) = 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")
@@ -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(
@@ -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<LinkDesktopStep>
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
@@ -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()
}
}
@@ -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<LinkMobileStep>
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 {
+1
View File
@@ -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)
@@ -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.
@@ -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.
@@ -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
/**
@@ -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,
@@ -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.
*/
@@ -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.
@@ -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 {
@@ -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
@@ -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<ShareLocationState> {
@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()
@@ -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(
@@ -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
@@ -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<ShowLocationState> {
@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() {
@@ -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(
@@ -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<ShowLocationState> {
@@ -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(),
@@ -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<MapTilerConfig?> = { 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()
@@ -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(),
@@ -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()
+2
View File
@@ -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)
@@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
@@ -33,13 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
@@ -129,12 +126,6 @@ class LoginFlowNode(
@Parcelize
data object ChooseAccountProvider : NavTarget
@Parcelize
data object ChangeAccountProvider : NavTarget
@Parcelize
data object SearchAccountProvider : NavTarget
@Parcelize
data class LoginPassword(
val initialLogin: String = "",
@@ -261,43 +252,9 @@ class LoginFlowNode(
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
override fun navigateToChangeAccountProvider() {
backstack.push(NavTarget.ChangeAccountProvider)
}
}
createNode<ConfirmAccountProviderNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.ChangeAccountProvider -> {
val callback = object : ChangeAccountProviderNode.Callback {
override fun onDone() {
// Go back to the Account Provider screen
val confirmAccountProvider = backstack.elements.value.firstOrNull {
it.key.navTarget is NavTarget.ConfirmAccountProvider
}?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
backstack.singleTop(confirmAccountProvider)
}
override fun navigateToSearchAccountProvider() {
backstack.push(NavTarget.SearchAccountProvider)
}
}
createNode<ChangeAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.SearchAccountProvider -> {
val callback = object : SearchAccountProviderNode.Callback {
override fun onDone() {
// Go back to the Account Provider screen
val confirmAccountProvider = backstack.elements.value.firstOrNull {
it.key.navTarget is NavTarget.ConfirmAccountProvider
}?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
backstack.singleTop(confirmAccountProvider)
}
}
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.LoginPassword -> {
val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin,
@@ -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(),
)
}
}
@@ -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<AccountProvider> = MutableStateFlow(defaultAccountProvider)
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider)
val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
// The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
// Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
// successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
var lastSelectedAccountProviderUrl: String? = null
private set
init {
// Seed the default from the last used provider, unless the user has already selected one.
coroutineScope.launch {
val default = defaultAccountProvider()
accountProvider.update { current -> if (current == configuredAccountProvider) default else current }
}
}
suspend fun reset() {
accountProvider.emit(defaultAccountProvider)
accountProvider.emit(defaultAccountProvider())
}
/**
* The provider to default to: the most recently used one from history when the user is free to
* connect to any provider, otherwise the enterprise/MDM-configured provider.
*/
private suspend fun defaultAccountProvider(): AccountProvider {
if (!enterpriseService.canConnectToAnyHomeserver()) {
return configuredAccountProvider
}
val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider
}
suspend fun setUrl(url: String) {
@@ -41,6 +78,7 @@ class AccountProviderDataSource(
}
suspend fun setAccountProvider(data: AccountProvider) {
lastSelectedAccountProviderUrl = data.url
accountProvider.emit(data)
}
@@ -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 = { },
)
}
@@ -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)
}
}
@@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
@Inject
class ChangeServerPresenter(
@@ -32,7 +33,7 @@ class ChangeServerPresenter(
) : Presenter<ChangeServerState> {
@Composable
override fun present(): ChangeServerState {
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
val changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
@@ -59,7 +60,7 @@ class ChangeServerPresenter(
private suspend fun changeServer(
data: AccountProvider,
changeServerAction: MutableState<AsyncData<Unit>>,
changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>,
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
@@ -72,6 +73,8 @@ class ChangeServerPresenter(
}
// Homeserver is valid, remember user choice
accountProviderDataSource.setAccountProvider(data)
// Return the resolved details so the caller can sign in without configuring the homeserver again.
details
}.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
}
}
@@ -9,10 +9,13 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class ChangeServerState(
val changeServerAction: AsyncData<Unit>,
// On success, carries the resolved homeserver details so the caller can proceed with login without
// configuring (and re-networking) the homeserver a second time.
val changeServerAction: AsyncData<MatrixHomeServerDetails>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
@@ -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<ChangeServerState> {
@@ -45,7 +46,7 @@ open class ChangeServerStatePreviewParam : PreviewParameterProvider<ChangeServer
}
fun aChangeServerState(
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
changeServerAction: AsyncData<MatrixHomeServerDetails> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState(
changeServerAction = changeServerAction,
@@ -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
@@ -13,6 +13,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
@@ -39,6 +40,7 @@ class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter<LoginModeState> {
@Composable
override fun present(): LoginModeState {
@@ -73,26 +75,28 @@ class LoginModePresenter(
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState<AsyncData<LoginMode>>) {
suspend {
authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
when {
matrixHomeServerDetails.supportsOAuthLogin -> {
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
// Reuse the details the caller already resolved when it configured the homeserver, so we don't
// run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves.
val matrixHomeServerDetails = request.preConfiguredDetails
?: authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
request.isAccountCreation -> throw AccountCreationNotSupported()
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported authentication flow")
}.getOrThrow()
when {
matrixHomeServerDetails.supportsOAuthLogin -> {
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
}
}.getOrThrow()
request.isAccountCreation -> throw AccountCreationNotSupported()
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported authentication flow")
}
}.runCatchingUpdatingState(
state = loginMode,
errorTransform = {
@@ -116,6 +120,7 @@ class LoginModePresenter(
.onSuccess { loginMode.value = AsyncData.Uninitialized }
.onFailure { loginMode.value = AsyncData.Failure(it) }
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
.onSuccess { saveAccountProviderToHistory() }
.onFailure { loginMode.value = AsyncData.Failure(it) }
}
oAuthActionFlow.reset()
@@ -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,
)
@@ -1,89 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Collections
/**
* Resolve homeserver base on search terms.
*/
@Inject
class HomeserverResolver(
private val dispatchers: CoroutineDispatchers,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim()
val searchTerm = when {
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
trimmedUserInput.startsWith("@") -> return@flow
else -> trimmedUserInput
}
if (searchTerm.length < 4) return@flow
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel
withContext(dispatchers.io) {
list.parallelMap { url ->
// Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
// otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
// candidate directly; the actual sign-in flow triggers the permission prompt.
val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
homeServerLoginCompatibilityChecker.check(url)
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
.getOrNull()
?: return@parallelMap
// Emit the list as soon as possible
if (shouldRequestPermissionOrIsValid) {
currentList.add(HomeserverData(homeserverUrl = url))
withContext(flowContext) {
emit(currentList.toList())
}
}
}
}
// If list is empty, and candidateBase is a valid an URL, do not block the user.
// A unsupported homeserver / homeserver not found error will be displayed if the user continues
if (currentList.isEmpty() && candidateBase.isValidUrl()) {
emit(listOf(HomeserverData(homeserverUrl = candidateBase)))
}
}
private fun getUrlCandidates(data: String): List<String> {
return buildList {
if (data.contains(".")) {
// TLD detected?
} else {
add("$data.org")
add("$data.com")
add("$data.io")
}
// Always try what the user has entered
add(data)
}
}
}
@@ -1,51 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.callback
@ContributesNode(AppScope::class)
@AssistedInject
class ChangeAccountProviderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChangeAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
fun navigateToSearchAccountProvider()
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ChangeAccountProviderView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onLearnMoreClick = { openLearnMorePage(context) },
onSuccess = callback::onDone,
onOtherProviderClick = callback::navigateToSearchAccountProvider,
)
}
}
@@ -1,57 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import kotlinx.collections.immutable.toImmutableList
@Inject
class ChangeAccountProviderPresenter(
private val changeServerPresenter: Presenter<ChangeServerState>,
private val enterpriseService: EnterpriseService,
) : Presenter<ChangeAccountProviderState> {
@Composable
override fun present(): ChangeAccountProviderState {
val staticAccountProviderList = remember {
enterpriseService.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,
)
}
}
@@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.changeaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import kotlinx.collections.immutable.ImmutableList
data class ChangeAccountProviderState(
val accountProviders: ImmutableList<AccountProvider>,
val canSearchForAccountProviders: Boolean,
val changeServerState: ChangeServerState,
)
@@ -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<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf(
aChangeAccountProviderState(),
aChangeAccountProviderState(canSearchForAccountProviders = false),
// Add other state here
)
}
fun aChangeAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
anAccountProvider()
),
canSearchForAccountProviders: Boolean = true,
changeServerState: ChangeServerState = aChangeServerState(),
) = ChangeAccountProviderState(
accountProviders = accountProviders.toImmutableList(),
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState,
)

Some files were not shown because too many files have changed in this diff Show More