Merge branch 'develop' into renovate/com.google.firebase-firebase-bom-33.x

This commit is contained in:
ganfra 2025-05-23 11:50:27 +02:00 committed by GitHub
commit f7b5423aae
60 changed files with 1164 additions and 369 deletions

View file

@ -11,7 +11,7 @@ env:
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
ARCH: x86_64 ARCH: x86_64
DEVICE: pixel_7_pro DEVICE: pixel_7_pro
API_LEVEL: 35 API_LEVEL: 33
TARGET: google_apis TARGET: google_apis
jobs: jobs:

View file

@ -1,4 +1,5 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
androidWebViewHierarchy: devtools
--- ---
## Check that all env variables required in the whole test suite are declared (to fail faster) ## Check that all env variables required in the whole test suite are declared (to fail faster)
- runScript: ./scripts/checkEnv.js - runScript: ./scripts/checkEnv.js

View file

@ -14,7 +14,22 @@ appId: ${MAESTRO_APP_ID}
visible: 'Use without an account' visible: 'Use without an account'
commands: commands:
- tapOn: "Use without an account" - tapOn: "Use without an account"
## For older chrome versions
- runFlow:
when:
visible: 'Accept & continue'
commands:
- tapOn: "Accept & continue"
- runFlow:
when:
visible: 'No thanks'
commands:
- tapOn: "No thanks"
## Working when running Maestro locally, but not on the CI yet. ## Working when running Maestro locally, but not on the CI yet.
- extendedWaitUntil:
visible:
id: "form-1"
timeout: 10000
- tapOn: - tapOn:
id: "form-1" id: "form-1"
- inputText: ${MAESTRO_USERNAME} - inputText: ${MAESTRO_USERNAME}

View file

@ -1,3 +1,50 @@
<!-- Release notes generated using configuration in .github/release.yml at v25.05.4 -->
Changes in Element X v25.05.4
=============================
Rust SDK: https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-ffi%2F20250521
## What's Changed
### 🙌 Improvements
* Change (report room) : check if server supports the report room api by @ganfra in https://github.com/element-hq/element-x-android/pull/4718
### 🐛 Bugfixes
* Improve audio focus management by @bmarty in https://github.com/element-hq/element-x-android/pull/4707
* When transcoding a video fails, send it as a file by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4257
* Disable mutliple click (parallel or serial) on a room by @bmarty in https://github.com/element-hq/element-x-android/pull/4683
* Fix generic mime type used when externally sharing several files by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4715
* Fix issues on JoinedRoom / BaseRoom by @bmarty in https://github.com/element-hq/element-x-android/pull/4724
* Use the right live timeline instance in `RustRoomFactory` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4745
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4739
### 🧱 Build
* Ensure the CI is marked as failed when Maestro test is failing by @bmarty in https://github.com/element-hq/element-x-android/pull/4700
* Trigger pipeline build when a release tag is pushed by @bmarty in https://github.com/element-hq/element-x-android/pull/4741
* Fix compilation issues. by @bmarty in https://github.com/element-hq/element-x-android/pull/4750
### 📄 Documentation
* README.md: fix broken link by @richvdh in https://github.com/element-hq/element-x-android/pull/4728
### Dependency upgrades
* chore(config): migrate renovate config by @renovate in https://github.com/element-hq/element-x-android/pull/4688
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.13 by @renovate in https://github.com/element-hq/element-x-android/pull/4716
* fix(deps): update dependency io.sentry:sentry-android to v8.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4717
* chore(deps): update plugin sonarqube to v6.2.0.5505 by @renovate in https://github.com/element-hq/element-x-android/pull/4725
* fix(deps): update dependency com.posthog:posthog-android to v3.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4723
* fix(deps): update dependency com.squareup.retrofit2:retrofit-bom to v2.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4727
* chore(deps): update codecov/codecov-action action to v5.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4730
* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4713
* fix(deps): update dependency com.squareup.retrofit2:retrofit-bom to v3 by @renovate in https://github.com/element-hq/element-x-android/pull/4729
* fix(deps): update kotlinpoet to v2.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4732
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.21 by @renovate in https://github.com/element-hq/element-x-android/pull/4759
### Others
* Remove event cache feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4719
* Check homeserver when login using qr code by @bmarty in https://github.com/element-hq/element-x-android/pull/4708
* Merge on boarding module to login module by @bmarty in https://github.com/element-hq/element-x-android/pull/4746
* Allow configuration to provide multiple account providers. by @bmarty in https://github.com/element-hq/element-x-android/pull/4742
* Reduce API of JoinedRoom, caller must use the Timeline API from liveTimeline instead by @bmarty in https://github.com/element-hq/element-x-android/pull/4731
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.3...v25.05.4
Changes in Element X v25.05.3 Changes in Element X v25.05.3
============================= =============================

View file

@ -34,11 +34,17 @@
android:value='androidx.startup' /> android:value='androidx.startup' />
</provider> </provider>
<!--
Using launchMode singleTask to avoid multiple instances of the Activity
when the app is already open. This is important for incoming share (see
https://github.com/element-hq/element-x-android/issues/4074) and for opening
the application from a mobile.element.io link.
-->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:theme="@style/Theme.ElementX.Splash" android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
@ -54,6 +60,9 @@
android:host="open" android:host="open"
android:scheme="elementx" /> android:scheme="elementx" />
</intent-filter> </intent-filter>
<!--
Oidc redirection
-->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -80,6 +89,21 @@
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json --> <!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
<data android:host="staging.element.io" /> <data android:host="staging.element.io" />
</intent-filter> </intent-filter>
<!--
Element mobile links
Example: https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://mobile.element.io/.well-known/assetlinks.json -->
<data android:host="mobile.element.io" />
<data android:path="/element" />
</intent-filter>
<!-- <!--
matrix.to links matrix.to links
Note: On Android 12 and higher clicking a web link (that is not an Android App Link) always shows content in a web browser Note: On Android 12 and higher clicking a web link (that is not an Android App Link) always shows content in a web browser

View file

@ -53,6 +53,7 @@ dependencies {
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.impl) testImplementation(projects.libraries.oidc.impl)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View file

@ -24,8 +24,11 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -46,10 +49,16 @@ class NotLoggedInFlowNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ) {
data class Params(
val loginParams: LoginParams?,
) : NodeInputs
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
} }
private val inputs = inputs<Params>()
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
@ -74,6 +83,12 @@ class NotLoggedInFlowNode @AssistedInject constructor(
} }
loginEntryPoint loginEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
.params(
LoginEntryPoint.Params(
accountProvider = inputs.loginParams?.accountProvider,
loginHint = inputs.loginParams?.loginHint,
)
)
.callback(callback) .callback(callback)
.build() .build()
} }

View file

@ -33,6 +33,8 @@ import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView import io.element.android.appnav.root.RootView
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
@ -40,6 +42,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -61,6 +64,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val enterpriseService: EnterpriseService,
private val navStateFlowFactory: RootNavStateFlowFactory, private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixSessionCache: MatrixSessionCache, private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter, private val presenter: RootPresenter,
@ -99,14 +103,14 @@ class RootFlowNode @AssistedInject constructor(
if (navState.loggedInState.isTokenValid) { if (navState.loggedInState.isTokenValid) {
tryToRestoreLatestSession( tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow() } onFailure = { switchToNotLoggedInFlow(null) }
) )
} else { } else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
} }
} }
LoggedInState.NotLoggedIn -> { LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow() switchToNotLoggedInFlow(null)
} }
} }
} }
@ -117,9 +121,9 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
} }
private fun switchToNotLoggedInFlow() { private fun switchToNotLoggedInFlow(params: LoginParams?) {
matrixSessionCache.removeAll() matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow) backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
} }
private fun switchToSignedOutFlow(sessionId: SessionId) { private fun switchToSignedOutFlow(sessionId: SessionId) {
@ -175,7 +179,9 @@ class RootFlowNode @AssistedInject constructor(
data object SplashScreen : NavTarget data object SplashScreen : NavTarget
@Parcelize @Parcelize
data object NotLoggedInFlow : NavTarget data class NotLoggedInFlow(
val params: LoginParams?
) : NavTarget
@Parcelize @Parcelize
data class LoggedInFlow( data class LoggedInFlow(
@ -211,13 +217,16 @@ class RootFlowNode @AssistedInject constructor(
} }
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.NotLoggedInFlow -> { is NavTarget.NotLoggedInFlow -> {
val callback = object : NotLoggedInFlowNode.Callback { val callback = object : NotLoggedInFlowNode.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport) backstack.push(NavTarget.BugReport)
} }
} }
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(callback)) val params = NotLoggedInFlowNode.Params(
loginParams = navTarget.params,
)
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
} }
is NavTarget.SignedOutFlow -> { is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext) signedOutEntryPoint.nodeBuilder(this, buildContext)
@ -272,18 +281,36 @@ class RootFlowNode @AssistedInject constructor(
val resolvedIntent = intentResolver.resolve(intent) ?: return val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) { when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData) is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
} }
} }
private suspend fun onLoginLink(params: LoginParams) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
switchToNotLoggedInFlow(params)
} else {
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
switchToNotLoggedInFlow(null)
}
} else {
// Just ignore the login link if we already have a session
Timber.w("Login link ignored, we already have a session")
}
}
private suspend fun onIncomingShare(intent: Intent) { private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already? // Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId() val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
// No session, open login // No session, open login
switchToNotLoggedInFlow() switchToNotLoggedInFlow(null)
} else { } else {
attachSession(latestSessionId) attachSession(latestSessionId)
.attachIncomingShare(intent) .attachIncomingShare(intent)

View file

@ -8,6 +8,8 @@
package io.element.android.appnav.intent package io.element.android.appnav.intent
import android.content.Intent import android.content.Intent
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -21,11 +23,13 @@ sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent data class IncomingShare(val intent: Intent) : ResolvedIntent
} }
class IntentResolver @Inject constructor( class IntentResolver @Inject constructor(
private val deeplinkParser: DeeplinkParser, private val deeplinkParser: DeeplinkParser,
private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver, private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
) { ) {
@ -40,10 +44,17 @@ class IntentResolver @Inject constructor(
val oidcAction = oidcIntentResolver.resolve(intent) val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction) if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
// External link clicked? (matrix.to, element.io, etc.) val actionViewData = intent
val permalinkData = intent
.takeIf { it.action == Intent.ACTION_VIEW } .takeIf { it.action == Intent.ACTION_VIEW }
?.dataString ?.dataString
// Mobile configuration link clicked? (mobile.element.io)
val mobileLoginData = actionViewData
?.let { loginIntentResolver.parse(it) }
if (mobileLoginData != null) return ResolvedIntent.Login(mobileLoginData)
// External link clicked? (matrix.to, element.io, etc.)
val permalinkData = actionViewData
?.let { permalinkParser.parse(it) } ?.let { permalinkParser.parse(it) }
?.takeIf { it !is PermalinkData.FallbackLink } ?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)

View file

@ -12,6 +12,8 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.libraries.deeplink.DeepLinkCreator import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
@ -165,6 +167,7 @@ class IntentResolverTest {
userId = UserId("@alice:matrix.org") userId = UserId("@alice:matrix.org")
) )
val sut = createIntentResolver( val sut = createIntentResolver(
loginIntentResolverResult = { null },
permalinkParserResult = { permalinkData } permalinkParserResult = { permalinkData }
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -182,7 +185,8 @@ class IntentResolverTest {
@Test @Test
fun `test resolve external permalink, FallbackLink should be ignored`() { fun `test resolve external permalink, FallbackLink should be ignored`() {
val sut = createIntentResolver( val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) } permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
@ -231,7 +235,8 @@ class IntentResolverTest {
@Test @Test
fun `test resolve invalid`() { fun `test resolve invalid`() {
val sut = createIntentResolver( val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) } permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
@ -241,11 +246,29 @@ class IntentResolverTest {
assertThat(result).isNull() assertThat(result).isNull()
} }
@Test
fun `test resolve login param`() {
val aLoginParams = LoginParams("accountProvider", null)
val sut = createIntentResolver(
loginIntentResolverResult = { aLoginParams },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.Login(aLoginParams))
}
private fun createIntentResolver( private fun createIntentResolver(
permalinkParserResult: (String) -> PermalinkData = { lambdaError() } permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
): IntentResolver { ): IntentResolver {
return IntentResolver( return IntentResolver(
deeplinkParser = DeeplinkParser(), deeplinkParser = DeeplinkParser(),
loginIntentResolver = FakeLoginIntentResolver(
parseResult = loginIntentResolverResult,
),
oidcIntentResolver = DefaultOidcIntentResolver( oidcIntentResolver = DefaultOidcIntentResolver(
oidcUrlParser = DefaultOidcUrlParser( oidcUrlParser = DefaultOidcUrlParser(
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(), oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -13,6 +13,11 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
interface LoginEntryPoint : FeatureEntryPoint { interface LoginEntryPoint : FeatureEntryPoint {
data class Params(
val accountProvider: String?,
val loginHint: String?,
)
interface Callback : Plugin { interface Callback : Plugin {
fun onReportProblem() fun onReportProblem()
} }
@ -20,6 +25,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder { interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder fun callback(callback: Callback): NodeBuilder
fun build(): Node fun build(): Node
} }

View file

@ -0,0 +1,12 @@
/*
* 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.api
interface LoginIntentResolver {
fun parse(uriString: String): LoginParams?
}

View file

@ -0,0 +1,21 @@
/*
* 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.api
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Parameters to start the login flow, when the application is opened
* from a mobile.element.io link.
*/
@Parcelize
data class LoginParams(
val accountProvider: String,
val loginHint: String?
) : Parcelable

View file

@ -58,6 +58,7 @@ dependencies {
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)

View file

@ -22,6 +22,14 @@ class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
val plugins = ArrayList<Plugin>() val plugins = ArrayList<Plugin>()
return object : LoginEntryPoint.NodeBuilder { return object : LoginEntryPoint.NodeBuilder {
override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder {
plugins += LoginFlowNode.Params(
accountProvider = params.accountProvider,
loginHint = params.loginHint,
)
return this
}
override fun callback(callback: LoginEntryPoint.Callback): LoginEntryPoint.NodeBuilder { override fun callback(callback: LoginEntryPoint.Callback): LoginEntryPoint.NodeBuilder {
plugins += callback plugins += callback
return this return this

View file

@ -0,0 +1,30 @@
/*
* 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
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLoginIntentResolver @Inject constructor() : LoginIntentResolver {
override fun parse(uriString: String): LoginParams? {
val uri = uriString.toUri()
if (uri.host != "mobile.element.io") return null
if (uri.path?.startsWith("/element")?.not() == true) return null
val accountProvider = uri.getQueryParameter("account_provider") ?: return null
val loginHint = uri.getQueryParameter("login_hint")
return LoginParams(
accountProvider = accountProvider,
loginHint = loginHint,
)
}
}

View file

@ -28,16 +28,18 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.onboarding.OnBoardingNode
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
@ -63,6 +65,11 @@ class LoginFlowNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ) {
data class Params(
val accountProvider: String?,
val loginHint: String?,
) : NodeInputs
private var activity: Activity? = null private var activity: Activity? = null
private var darkTheme: Boolean = false private var darkTheme: Boolean = false
@ -139,8 +146,25 @@ class LoginFlowNode @AssistedInject constructor(
override fun onReportProblem() { override fun onReportProblem() {
plugins<LoginEntryPoint.Callback>().forEach { it.onReportProblem() } plugins<LoginEntryPoint.Callback>().forEach { it.onReportProblem() }
} }
override fun onOidcDetails(oidcDetails: OidcDetails) {
navigateToMas(oidcDetails)
}
override fun onCreateAccountContinue(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPassword)
}
} }
createNode<OnBoardingNode>(buildContext, listOf(callback)) val params = inputs<Params>()
val inputs = OnBoardingNode.Params(
accountProvider = params.accountProvider,
loginHint = params.loginHint,
)
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
} }
NavTarget.QrCode -> { NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext) createNode<QrCodeLoginFlowNode>(buildContext)
@ -151,16 +175,7 @@ class LoginFlowNode @AssistedInject constructor(
) )
val callback = object : ConfirmAccountProviderNode.Callback { val callback = object : ConfirmAccountProviderNode.Callback {
override fun onOidcDetails(oidcDetails: OidcDetails) { override fun onOidcDetails(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) { navigateToMas(oidcDetails)
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
}
} }
override fun onCreateAccountContinue(url: String) { override fun onCreateAccountContinue(url: String) {
@ -222,6 +237,19 @@ class LoginFlowNode @AssistedInject constructor(
} }
} }
private fun navigateToMas(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
activity = requireNotNull(LocalActivity.current) activity = requireNotNull(LocalActivity.current)

View file

@ -0,0 +1,119 @@
/*
* 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.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcPrompt
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests.
* It's an helper to avoid code duplication. It is used by [OnBoardingPresenter] and [ConfirmAccountProviderPresenter].
*/
class LoginHelper @Inject constructor(
private val oidcActionFlow: OidcActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) {
private val loginModeState: MutableState<AsyncData<LoginMode>> = mutableStateOf(AsyncData.Uninitialized)
@Composable
fun collectLoginMode(): State<AsyncData<LoginMode>> {
LaunchedEffect(Unit) {
oidcActionFlow.collect { oidcAction ->
if (oidcAction != null) {
onOidcAction(oidcAction)
}
}
}
return loginModeState
}
fun clearError() {
loginModeState.value = AsyncData.Uninitialized
}
fun submit(
coroutineScope: CoroutineScope,
isAccountCreation: Boolean,
homeserverUrl: String,
loginHint: String?,
) = coroutineScope.launch {
suspend {
authenticationService.setHomeserver(homeserverUrl).map {
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
LoginMode.Oidc(
authenticationService.getOidcUrl(prompt = oidcPrompt, loginHint = loginHint).getOrThrow()
)
} else if (isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginMode.AccountCreation(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginMode.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginModeState,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOidcAction(oidcAction: OidcAction) {
loginModeState.value = AsyncData.Loading()
when (oidcAction) {
OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
.onSuccess { _ ->
defaultLoginUserStory.setLoginFlowIsDone(true)
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
}
oidcActionFlow.reset()
}
}

View file

@ -0,0 +1,16 @@
/*
* 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.login
import io.element.android.libraries.matrix.api.auth.OidcDetails
sealed interface LoginMode {
data object PasswordLogin : LoginMode
data class Oidc(val oidcDetails: OidcDetails) : LoginMode
data class AccountCreation(val url: String) : LoginMode
}

View file

@ -0,0 +1,87 @@
/*
* 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.login
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoginModeView(
loginMode: AsyncData<LoginMode>,
onClearError: () -> Unit,
onLearnMoreClick: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit
) {
when (loginMode) {
is AsyncData.Failure -> {
when (val error = loginMode.error) {
is ChangeServerError -> {
when (error) {
is ChangeServerError.Error -> {
ErrorDialog(
content = error.message(),
onSubmit = onClearError,
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(
onLearnMoreClick = {
onLearnMoreClick()
onClearError()
},
onDismiss = onClearError,
)
}
is ChangeServerError.UnauthorizedAccountProvider -> {
ErrorDialog(
content = stringResource(
id = R.string.screen_change_server_error_unauthorized_homeserver,
LocalBuildMeta.current.applicationName,
error.unauthorisedAccountProviderTitle,
),
onSubmit = onClearError,
)
}
}
}
is AccountCreationNotSupported -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_creation_not_possible),
onSubmit = onClearError,
)
}
else -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = onClearError,
)
}
}
}
is AsyncData.Loading -> Unit // The Continue button shows the loading state
is AsyncData.Success -> {
when (val loginModeData = loginMode.data) {
is LoginMode.Oidc -> onOidcDetails(loginModeData.oidcDetails)
LoginMode.PasswordLogin -> onNeedLoginPassword()
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
}
}
AsyncData.Uninitialized -> Unit
}
}

View file

@ -1,44 +0,0 @@
/*
* Copyright 2023, 2024 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.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import javax.inject.Inject
/**
* Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold.
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
*/
class OnBoardingPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<OnBoardingState> {
@Composable
override fun present(): OnBoardingState {
val canLoginWithQrCode by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
)
}
}

View file

@ -1,15 +0,0 @@
/*
* Copyright 2023, 2024 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.onboarding
data class OnBoardingState(
val productionApplicationName: String,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
val canReportBug: Boolean,
)

View file

@ -8,38 +8,20 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcPrompt
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ConfirmAccountProviderPresenter @AssistedInject constructor( class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Assisted private val params: Params, @Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val authenticationService: MatrixAuthenticationService, private val loginHelper: LoginHelper,
private val oidcActionFlow: OidcActionFlow,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) : Presenter<ConfirmAccountProviderState> { ) : Presenter<ConfirmAccountProviderState> {
data class Params( data class Params(
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
@ -55,91 +37,27 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
val accountProvider by accountProviderDataSource.flow.collectAsState() val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember { val loginMode by loginHelper.collectLoginMode()
mutableStateOf(AsyncData.Uninitialized)
}
LaunchedEffect(Unit) {
oidcActionFlow.collect { oidcAction ->
if (oidcAction != null) {
onOidcAction(oidcAction, loginFlowAction)
}
}
}
fun handleEvents(event: ConfirmAccountProviderEvents) { fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) { when (event) {
ConfirmAccountProviderEvents.Continue -> { ConfirmAccountProviderEvents.Continue -> {
localCoroutineScope.submit(accountProvider.url, loginFlowAction) loginHelper.submit(
coroutineScope = localCoroutineScope,
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
loginHint = null,
)
} }
ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = AsyncData.Uninitialized ConfirmAccountProviderEvents.ClearError -> loginHelper.clearError()
} }
} }
return ConfirmAccountProviderState( return ConfirmAccountProviderState(
accountProvider = accountProvider, accountProvider = accountProvider,
isAccountCreation = params.isAccountCreation, isAccountCreation = params.isAccountCreation,
loginFlow = loginFlowAction.value, loginMode = loginMode,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.submit(
homeserverUrl: String,
loginFlowAction: MutableState<AsyncData<LoginFlow>>,
) = launch {
suspend {
authenticationService.setHomeserver(homeserverUrl).map {
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
val oidcPrompt = if (params.isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
LoginFlow.OidcFlow(authenticationService.getOidcUrl(oidcPrompt).getOrThrow())
} else if (params.isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginFlow.AccountCreationFlow(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginFlowAction,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOidcAction(
oidcAction: OidcAction,
loginFlowAction: MutableState<AsyncData<LoginFlow>>,
) {
loginFlowAction.value = AsyncData.Loading()
when (oidcAction) {
OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loginFlowAction.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginFlowAction.value = AsyncData.Failure(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
.onSuccess { _ ->
defaultLoginUserStory.setLoginFlowIsDone(true)
}
.onFailure { failure ->
loginFlowAction.value = AsyncData.Failure(failure)
}
}
}
oidcActionFlow.reset()
}
} }

View file

@ -8,21 +8,15 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails
// Do not use default value, so no member get forgotten in the presenters. // Do not use default value, so no member get forgotten in the presenters.
data class ConfirmAccountProviderState( data class ConfirmAccountProviderState(
val accountProvider: AccountProvider, val accountProvider: AccountProvider,
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
val loginFlow: AsyncData<LoginFlow>, val loginMode: AsyncData<LoginMode>,
val eventSink: (ConfirmAccountProviderEvents) -> Unit val eventSink: (ConfirmAccountProviderEvents) -> Unit
) { ) {
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginFlow is AsyncData.Uninitialized || loginFlow is AsyncData.Loading) val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
}
sealed interface LoginFlow {
data object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
data class AccountCreationFlow(val url: String) : LoginFlow
} }

View file

@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider 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.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -22,7 +23,7 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
), ),
aConfirmAccountProviderState( aConfirmAccountProviderState(
isAccountCreation = true, isAccountCreation = true,
loginFlow = AsyncData.Failure(AccountCreationNotSupported()) loginMode = AsyncData.Failure(AccountCreationNotSupported())
), ),
) )
} }
@ -30,11 +31,11 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
private fun aConfirmAccountProviderState( private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(), accountProvider: AccountProvider = anAccountProvider(),
isAccountCreation: Boolean = false, isAccountCreation: Boolean = false,
loginFlow: AsyncData<LoginFlow> = AsyncData.Uninitialized, loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (ConfirmAccountProviderEvents) -> Unit = {}, eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState( ) = ConfirmAccountProviderState(
accountProvider = accountProvider, accountProvider = accountProvider,
isAccountCreation = isAccountCreation, isAccountCreation = isAccountCreation,
loginFlow = loginFlow, loginMode = loginMode,
eventSink = eventSink eventSink = eventSink
) )

View file

@ -19,15 +19,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -47,9 +44,9 @@ fun ConfirmAccountProviderView(
onChange: () -> Unit, onChange: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isLoading by remember(state.loginFlow) { val isLoading by remember(state.loginMode) {
derivedStateOf { derivedStateOf {
state.loginFlow is AsyncData.Loading state.loginMode is AsyncData.Loading
} }
} }
val eventSink = state.eventSink val eventSink = state.eventSink
@ -99,48 +96,16 @@ fun ConfirmAccountProviderView(
} }
} }
) { ) {
when (state.loginFlow) { LoginModeView(
is AsyncData.Failure -> { loginMode = state.loginMode,
when (val error = state.loginFlow.error) { onClearError = {
is ChangeServerError.Error -> { eventSink(ConfirmAccountProviderEvents.ClearError)
ErrorDialog( },
content = error.message(), onLearnMoreClick = onLearnMoreClick,
onSubmit = { onOidcDetails = onOidcDetails,
eventSink.invoke(ConfirmAccountProviderEvents.ClearError) onNeedLoginPassword = onNeedLoginPassword,
} onCreateAccountContinue = onCreateAccountContinue,
) )
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(
onLearnMoreClick = {
onLearnMoreClick()
eventSink(ConfirmAccountProviderEvents.ClearError)
},
onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
}
)
}
is AccountCreationNotSupported -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_creation_not_possible),
onSubmit = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
}
}
is AsyncData.Loading -> Unit // The Continue button shows the loading state
is AsyncData.Success -> {
when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onNeedLoginPassword()
is LoginFlow.AccountCreationFlow -> onCreateAccountContinue(loginFlowState.url)
}
}
AsyncData.Uninitialized -> Unit
}
} }
} }

View file

@ -0,0 +1,16 @@
/*
* 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.screens.onboarding
sealed interface OnBoardingEvents {
data class OnSignIn(
val defaultAccountProvider: String
) : OnBoardingEvents
data object ClearError : OnBoardingEvents
}

View file

@ -5,10 +5,11 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.onboarding package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -16,13 +17,17 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class OnBoardingNode @AssistedInject constructor( class OnBoardingNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: OnBoardingPresenter, presenterFactory: OnBoardingPresenter.Factory,
) : Node( ) : Node(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
@ -32,8 +37,22 @@ class OnBoardingNode @AssistedInject constructor(
fun onSignIn() fun onSignIn()
fun onSignInWithQrCode() fun onSignInWithQrCode()
fun onReportProblem() fun onReportProblem()
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onCreateAccountContinue(url: String)
} }
data class Params(
val accountProvider: String?,
val loginHint: String?,
) : NodeInputs
private val params = inputs<Params>()
private val presenter = presenterFactory.create(
params = params,
)
private fun onSignIn() { private fun onSignIn() {
plugins<Callback>().forEach { it.onSignIn() } plugins<Callback>().forEach { it.onSignIn() }
} }
@ -50,9 +69,22 @@ class OnBoardingNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onReportProblem() } plugins<Callback>().forEach { it.onReportProblem() }
} }
private fun onOidcDetails(data: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(data) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onCreateAccountContinue(url: String) {
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val context = LocalContext.current
OnBoardingView( OnBoardingView(
state = state, state = state,
modifier = modifier, modifier = modifier,
@ -60,6 +92,10 @@ class OnBoardingNode @AssistedInject constructor(
onCreateAccount = ::onSignUp, onCreateAccount = ::onSignUp,
onSignInWithQrCode = ::onSignInWithQrCode, onSignInWithQrCode = ::onSignInWithQrCode,
onReportProblem = ::onReportProblem, onReportProblem = ::onReportProblem,
onOidcDetails = ::onOidcDetails,
onNeedLoginPassword = ::onLoginPasswordNeeded,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = ::onCreateAccountContinue,
) )
} }
} }

View file

@ -0,0 +1,77 @@
/*
* Copyright 2023, 2024 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.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
class OnBoardingPresenter @AssistedInject constructor(
@Assisted private val params: OnBoardingNode.Params,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
) : Presenter<OnBoardingState> {
@AssistedFactory
interface Factory {
fun create(
params: OnBoardingNode.Params,
): OnBoardingPresenter
}
private val defaultAccountProvider = params.accountProvider
private val loginHint = params.loginHint
@Composable
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val canLoginWithQrCode by produceState(initialValue = false) {
value = defaultAccountProvider == null &&
featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val loginMode by loginHelper.collectLoginMode()
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> loginHelper.submit(
coroutineScope = localCoroutineScope,
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
loginHint = loginHint,
)
OnBoardingEvents.ClearError -> loginHelper.clearError()
}
}
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
loginMode = loginMode,
eventSink = ::handleEvent,
)
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright 2023, 2024 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.onboarding
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
val productionApplicationName: String,
val defaultAccountProvider: String?,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
val canReportBug: Boolean,
val loginMode: AsyncData<LoginMode>,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = defaultAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
}

View file

@ -5,9 +5,11 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.onboarding package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> { open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
override val values: Sequence<OnBoardingState> override val values: Sequence<OnBoardingState>
@ -17,17 +19,24 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
anOnBoardingState(canCreateAccount = true), anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true), anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true), anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
) )
} }
fun anOnBoardingState( fun anOnBoardingState(
productionApplicationName: String = "Element", productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
canLoginWithQrCode: Boolean = false, canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false, canCreateAccount: Boolean = false,
canReportBug: Boolean = false, canReportBug: Boolean = false,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState( ) = OnBoardingState(
productionApplicationName = productionApplicationName, productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount, canCreateAccount = canCreateAccount,
canReportBug = canReportBug, canReportBug = canReportBug,
loginMode = loginMode,
eventSink = eventSink,
) )

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.onboarding package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -27,6 +30,8 @@ import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@ -37,6 +42,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -52,6 +58,10 @@ fun OnBoardingView(
onSignInWithQrCode: () -> Unit, onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit, onSignIn: () -> Unit,
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
onReportProblem: () -> Unit, onReportProblem: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -59,6 +69,16 @@ fun OnBoardingView(
modifier = modifier, modifier = modifier,
content = { content = {
OnBoardingContent(state = state) OnBoardingContent(state = state)
LoginModeView(
loginMode = state.loginMode,
onClearError = {
state.eventSink(OnBoardingEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
}, },
footer = { footer = {
OnBoardingButtons( OnBoardingButtons(
@ -127,6 +147,12 @@ private fun OnBoardingButtons(
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
onReportProblem: () -> Unit, onReportProblem: () -> Unit,
) { ) {
val isLoading by remember(state.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
}
}
ButtonColumnMolecule { ButtonColumnMolecule {
val signInButtonStringRes = if (state.canLoginWithQrCode || state.canCreateAccount) { val signInButtonStringRes = if (state.canLoginWithQrCode || state.canCreateAccount) {
R.string.screen_onboarding_sign_in_manually R.string.screen_onboarding_sign_in_manually
@ -141,13 +167,27 @@ private fun OnBoardingButtons(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
Button( val defaultAccountProvider = state.defaultAccountProvider
text = stringResource(id = signInButtonStringRes), if (defaultAccountProvider == null) {
onClick = onSignIn, Button(
modifier = Modifier text = stringResource(id = signInButtonStringRes),
.fillMaxWidth() onClick = onSignIn,
.testTag(TestTags.onBoardingSignIn) modifier = Modifier
) .fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)
)
} else {
Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider),
showProgress = isLoading,
onClick = {
state.eventSink(OnBoardingEvents.OnSignIn(defaultAccountProvider))
},
enabled = state.submitEnabled || isLoading,
modifier = Modifier
.fillMaxWidth()
)
}
if (state.canCreateAccount) { if (state.canCreateAccount) {
TextButton( TextButton(
text = stringResource(id = R.string.screen_onboarding_sign_up), text = stringResource(id = R.string.screen_onboarding_sign_up),
@ -181,5 +221,9 @@ internal fun OnBoardingViewPreview(
onSignIn = {}, onSignIn = {},
onCreateAccount = {}, onCreateAccount = {},
onReportProblem = {}, onReportProblem = {},
onOidcDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onCreateAccountContinue = {},
) )
} }

View file

@ -0,0 +1,81 @@
/*
* 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
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultLoginIntentResolverTest {
@Test
fun `nominal case`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = "mxid:@alice:example.org",
)
)
}
@Test
fun `extra unknown param`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org&extra=uknown"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = "mxid:@alice:example.org",
)
)
}
@Test
fun `no account provider`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `no path`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `wrong path`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/wrong?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `wrong host`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://wrong.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `no login_hint param`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?account_provider=example.org"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = null,
)
)
}
}

View file

@ -1,68 +0,0 @@
/*
* Copyright 2023, 2024 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.onboarding
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.appconfig.OnBoardingConfig
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OnBoardingPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta(
applicationName = "A",
productionApplicationName = "B",
desktopApplicationName = "C",
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
buildMeta = buildMeta,
)
val presenter = OnBoardingPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
@Test
fun `present - rageshake not available`() = runTest {
val presenter = OnBoardingPresenter(
buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(),
rageshakeFeatureAvailability = { false },
)
presenter.test {
skipItems(1)
assertThat(awaitItem().canReportBug).isFalse()
}
}
}

View file

@ -15,7 +15,9 @@ import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -25,6 +27,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
@ -46,7 +49,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue() assertThat(initialState.submitEnabled).isTrue()
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
assertThat(initialState.loginFlow).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.loginMode).isEqualTo(AsyncData.Uninitialized)
} }
} }
@ -64,11 +67,11 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue() assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.PasswordLogin) assertThat(successState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
} }
} }
@ -86,11 +89,11 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue() assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
} }
} }
@ -110,15 +113,15 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue() assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenOidcCancelError(A_THROWABLE) authenticationService.givenOidcCancelError(A_THROWABLE)
defaultOidcActionFlow.post(OidcAction.GoBack) defaultOidcActionFlow.post(OidcAction.GoBack)
val cancelFailureState = awaitItem() val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginFlow).isInstanceOf(AsyncData.Failure::class.java) assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
} }
} }
@ -138,14 +141,14 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue() assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
defaultOidcActionFlow.post(OidcAction.GoBack) defaultOidcActionFlow.post(OidcAction.GoBack)
val cancelFinalState = awaitItem() val cancelFinalState = awaitItem()
assertThat(cancelFinalState.loginFlow).isInstanceOf(AsyncData.Uninitialized::class.java) assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
} }
} }
@ -165,17 +168,17 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue() assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenLoginError(A_THROWABLE) authenticationService.givenLoginError(A_THROWABLE)
defaultOidcActionFlow.post(OidcAction.Success("aUrl")) defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
val cancelLoadingState = awaitItem() val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val cancelFailureState = awaitItem() val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginFlow).isInstanceOf(AsyncData.Failure::class.java) assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
} }
} }
@ -199,15 +202,15 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue() assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse() assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
defaultOidcActionFlow.post(OidcAction.Success("aUrl")) defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
val successSuccessState = awaitItem() val successSuccessState = awaitItem()
assertThat(successSuccessState.loginFlow).isInstanceOf(AsyncData.Loading::class.java) assertThat(successSuccessState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
waitForPredicate { defaultLoginUserStory.loginFlowIsDone.value } waitForPredicate { defaultLoginUserStory.loginFlowIsDone.value }
} }
} }
@ -227,7 +230,7 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading skipItems(1) // Loading
val failureState = awaitItem() val failureState = awaitItem()
assertThat(failureState.submitEnabled).isFalse() assertThat(failureState.submitEnabled).isFalse()
assertThat(failureState.loginFlow).isInstanceOf(AsyncData.Failure::class.java) assertThat(failureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
} }
} }
@ -250,12 +253,12 @@ class ConfirmAccountProviderPresenterTest {
// Check an error was returned // Check an error was returned
val submittedState = awaitItem() val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Failure::class.java) assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared // Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem() val clearedState = awaitItem()
assertThat(clearedState.loginFlow).isEqualTo(AsyncData.Uninitialized) assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
} }
} }
@ -278,11 +281,11 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading skipItems(1) // Loading
// Check an error was returned // Check an error was returned
val submittedState = awaitItem() val submittedState = awaitItem()
assertThat(submittedState.loginFlow.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) assertThat(submittedState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared // Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem() val clearedState = awaitItem()
assertThat(clearedState.loginFlow).isEqualTo(AsyncData.Uninitialized) assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
} }
} }
@ -301,8 +304,8 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading
val submittedState = awaitItem() val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
} }
} }
@ -323,8 +326,8 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading
val submittedState = awaitItem() val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java) assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
} }
} }
@ -345,7 +348,7 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading
val submittedState = awaitItem() val submittedState = awaitItem()
assertThat(submittedState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.AccountCreationFlow(aUrl)) assertThat(submittedState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
} }
} }
@ -353,15 +356,17 @@ class ConfirmAccountProviderPresenterTest {
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(), defaultOidcActionFlow: OidcActionFlow = DefaultOidcActionFlow(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
) = ConfirmAccountProviderPresenter( ) = ConfirmAccountProviderPresenter(
params = params, params = params,
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
authenticationService = matrixAuthenticationService, loginHelper = createLoginHelper(
oidcActionFlow = defaultOidcActionFlow, authenticationService = matrixAuthenticationService,
defaultLoginUserStory = defaultLoginUserStory, oidcActionFlow = defaultOidcActionFlow,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
),
) )
} }

View file

@ -0,0 +1,149 @@
/*
* Copyright 2023, 2024 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.onboarding
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OnBoardingPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta(
applicationName = "A",
productionApplicationName = "B",
desktopApplicationName = "C",
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
buildMeta = buildMeta,
)
val presenter = createPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = { true },
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
@Test
fun `present - rageshake not available`() = runTest {
val presenter = createPresenter(
rageshakeFeatureAvailability = { false },
)
presenter.test {
skipItems(1)
assertThat(awaitItem().canReportBug).isFalse()
}
}
@Test
fun `present - default account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
loginHint = null,
),
)
presenter.test {
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
assertThat(it.canLoginWithQrCode).isFalse()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test
fun `present - default account provider - login and clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
loginHint = A_LOGIN_HINT,
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE)
it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL))
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(OnBoardingEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
}
}
private fun createPresenter(
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper,
)
fun createLoginHelper(
oidcActionFlow: OidcActionFlow = DefaultOidcActionFlow(),
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
): LoginHelper = LoginHelper(
oidcActionFlow = oidcActionFlow,
authenticationService = authenticationService,
defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
)

View file

@ -5,16 +5,22 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.onboarding package io.element.android.features.login.impl.screens.onboarding
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule import org.junit.Rule
@ -74,6 +80,34 @@ class OnboardingViewTest {
} }
} }
@Test
fun `when sign in to pre defined account provider - clicking on button emits the expected event`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
rule.setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
eventSink = eventSink,
),
)
val buttonText = rule.activity.getString(R.string.screen_onboarding_sign_in_to, "element.io")
rule.onNodeWithText(buttonText).performClick()
eventSink.assertSingle(OnBoardingEvents.OnSignIn("element.io"))
}
@Test
fun `when error is displayed - closing the dialog emits the expected event`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
rule.setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink,
),
)
rule.clickOn(CommonStrings.action_ok)
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
@Test @Test
fun `clicking on report a problem calls the sign in callback`() { fun `clicking on report a problem calls the sign in callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@ -106,6 +140,10 @@ class OnboardingViewTest {
onSignIn: () -> Unit = EnsureNeverCalled(), onSignIn: () -> Unit = EnsureNeverCalled(),
onCreateAccount: () -> Unit = EnsureNeverCalled(), onCreateAccount: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(), onReportProblem: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setContent {
OnBoardingView( OnBoardingView(
@ -114,6 +152,10 @@ class OnboardingViewTest {
onSignIn = onSignIn, onSignIn = onSignIn,
onCreateAccount = onCreateAccount, onCreateAccount = onCreateAccount,
onReportProblem = onReportProblem, onReportProblem = onReportProblem,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
onCreateAccountContinue = onCreateAccountContinue,
) )
} }
} }

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.login.test"
}
dependencies {
implementation(projects.features.login.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,20 @@
/*
* 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.test
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLoginIntentResolver(
private val parseResult: (String) -> LoginParams? = { lambdaError() }
) : LoginIntentResolver {
override fun parse(uriString: String): LoginParams? {
return parseResult(uriString)
}
}

View file

@ -11,7 +11,7 @@ firebaseAppDistribution = "5.1.1"
# AndroidX # AndroidX
core = "1.16.0" core = "1.16.0"
datastore = "1.1.6" datastore = "1.1.7"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1" constraintlayout_compose = "1.1.1"
lifecycle = "2.9.0" lifecycle = "2.9.0"
@ -169,7 +169,7 @@ jsoup = "org.jsoup:jsoup:1.20.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.5.13" matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.5.21"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -183,7 +183,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.8.7" maplibre = "org.maplibre.gl:android-sdk:11.8.8"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0" opusencoder = "io.element.android:opusencoder:1.1.0"
@ -232,7 +232,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.8" detekt = "io.gitlab.arturbosch.detekt:1.23.8"
ktlint = "org.jlleitschuh.gradle.ktlint:12.2.0" ktlint = "org.jlleitschuh.gradle.ktlint:12.3.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.1.1" dependencycheck = "org.owasp.dependencycheck:12.1.1"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }

View file

@ -43,7 +43,10 @@ interface MatrixAuthenticationService {
/** /**
* Get the Oidc url to display to the user. * Get the Oidc url to display to the user.
*/ */
suspend fun getOidcUrl(prompt: OidcPrompt): Result<OidcDetails> suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails>
/** /**
* Cancel Oidc login sequence. * Cancel Oidc login sequence.

View file

@ -187,14 +187,17 @@ class RustMatrixAuthenticationService @Inject constructor(
private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null
override suspend fun getOidcUrl(prompt: OidcPrompt): Result<OidcDetails> { override suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails> {
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first") val client = currentClient ?: error("You need to call `setHomeserver()` first")
val oAuthAuthorizationData = client.urlForOidc( val oAuthAuthorizationData = client.urlForOidc(
oidcConfiguration = oidcConfigurationProvider.get(), oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(), prompt = prompt.toRustPrompt(),
loginHint = null, loginHint = loginHint,
) )
val url = oAuthAuthorizationData.loginUrl() val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthorizationData pendingOAuthAuthorizationData = oAuthAuthorizationData

View file

@ -49,6 +49,7 @@ class DefaultCallWidgetSettingsProvider @Inject constructor(
sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) "RELEASE" else "DEBUG", sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) "RELEASE" else "DEBUG",
parentUrl = null, parentUrl = null,
hideHeader = true, hideHeader = true,
controlledMediaDevices = false,
) )
val rustWidgetSettings = newVirtualElementCallWidget(options) val rustWidgetSettings = newVirtualElementCallWidget(options)
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings) return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)

View file

@ -19,7 +19,6 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
const val A_USER_NAME = "alice" const val A_USER_NAME = "alice"
const val A_USER_NAME_2 = "Bob" const val A_USER_NAME_2 = "Bob"
@ -73,7 +72,6 @@ const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false) val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true) val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE
val A_ROOM_NOTIFICATION_SETTINGS = RoomNotificationSettings(mode = A_ROOM_NOTIFICATION_MODE, isDefault = false)
const val AN_AVATAR_URL = "mxc://data" const val AN_AVATAR_URL = "mxc://data"
@ -88,3 +86,5 @@ val A_SERVER_LIST = listOf("server1", "server2")
const val A_TIMESTAMP = 567L const val A_TIMESTAMP = 567L
const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM" const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM"
const val A_LOGIN_HINT = "mxid:@alice:example.org"

View file

@ -87,7 +87,10 @@ class FakeMatrixAuthenticationService(
return importCreatedSessionLambda(externalSession) return importCreatedSessionLambda(externalSession)
} }
override suspend fun getOidcUrl(prompt: OidcPrompt): Result<OidcDetails> = simulateLongTask { override suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails> = simulateLongTask {
oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
} }

View file

@ -32,7 +32,7 @@ private const val versionYear = 25
private const val versionMonth = 5 private const val versionMonth = 5
// Note: must be in [0,99] // Note: must be in [0,99]
private const val versionReleaseNumber = 3 private const val versionReleaseNumber = 4
object Versions { object Versions {
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e93b2b62089570439aa10baf41d0ded8cdac23ba234a5bb32ba118f41e1f7385
size 313315

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa123acbffde2545f7f76ce58ede9d1fb80668d2d8576bf5f920a49f91c1e474
size 394540

12
tools/adb/deeplink_mobile.sh Executable file
View file

@ -0,0 +1,12 @@
#! /bin/bash
# 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.
# Format is:
# https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org
adb shell am start -a android.intent.action.VIEW \
-d "https://mobile.element.io/element?account_provider=element.io\\&login_hint=mxid:@alice:element.io"