Add support for login link (#4752)
* Add support for login link https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org * Update screenshots * Reduce code duplication * Add test on OnBoardingPresenter * Fix tool * Ignore login parameter if user is not allowed to connect to the provided server. * Improve tests. * Cleanup * Revert change on Project.xml. * Add documentation * Improve LoginHelper * Rename LoginFlow to LoginMode Move LoginFlow to package io.element.android.features.login.impl.login Rename some implementation of LoginMode Rename LoginFlowView to LoginModeView * Change launchMode of MainActivity from `singleTop` to `singleTask` Using launchMode singleTask to avoid multiple instances of the Activity when the app is already open. This is important for incoming share and for opening the application from a mobile.element.io link. Closes #4074 --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
bc759e4129
commit
f20caebdcb
52 changed files with 1092 additions and 363 deletions
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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