Merge branch 'develop' into renovate/com.google.firebase-firebase-bom-33.x
This commit is contained in:
commit
f7b5423aae
60 changed files with 1164 additions and 369 deletions
2
.github/workflows/maestro-local.yml
vendored
2
.github/workflows/maestro-local.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
47
CHANGES.md
47
CHANGES.md
|
|
@ -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
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
2
fastlane/metadata/android/en-US/changelogs/202505040.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202505040.txt
Normal 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
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
19
features/login/test/build.gradle.kts
Normal file
19
features/login/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e93b2b62089570439aa10baf41d0ded8cdac23ba234a5bb32ba118f41e1f7385
|
||||||
|
size 313315
|
||||||
|
|
@ -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
12
tools/adb/deeplink_mobile.sh
Executable 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue