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
60c4155108
commit
5a2aeac6b6
52 changed files with 1092 additions and 363 deletions
|
|
@ -24,8 +24,11 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
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.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.ScreenOrientation
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
|
@ -46,10 +49,16 @@ class NotLoggedInFlowNode @AssistedInject constructor(
|
|||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
data class Params(
|
||||
val loginParams: LoginParams?,
|
||||
) : NodeInputs
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onOpenBugReport()
|
||||
}
|
||||
|
||||
private val inputs = inputs<Params>()
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
|
|
@ -74,6 +83,12 @@ class NotLoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
loginEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
.params(
|
||||
LoginEntryPoint.Params(
|
||||
accountProvider = inputs.loginParams?.accountProvider,
|
||||
loginHint = inputs.loginParams?.loginHint,
|
||||
)
|
||||
)
|
||||
.callback(callback)
|
||||
.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.RootPresenter
|
||||
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.signedout.api.SignedOutEntryPoint
|
||||
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.createNode
|
||||
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.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
|
@ -61,6 +64,7 @@ class RootFlowNode @AssistedInject constructor(
|
|||
@Assisted val buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||
private val matrixSessionCache: MatrixSessionCache,
|
||||
private val presenter: RootPresenter,
|
||||
|
|
@ -99,14 +103,14 @@ class RootFlowNode @AssistedInject constructor(
|
|||
if (navState.loggedInState.isTokenValid) {
|
||||
tryToRestoreLatestSession(
|
||||
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
|
||||
onFailure = { switchToNotLoggedInFlow() }
|
||||
onFailure = { switchToNotLoggedInFlow(null) }
|
||||
)
|
||||
} else {
|
||||
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
|
||||
}
|
||||
}
|
||||
LoggedInState.NotLoggedIn -> {
|
||||
switchToNotLoggedInFlow()
|
||||
switchToNotLoggedInFlow(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,9 +121,9 @@ class RootFlowNode @AssistedInject constructor(
|
|||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
|
||||
}
|
||||
|
||||
private fun switchToNotLoggedInFlow() {
|
||||
private fun switchToNotLoggedInFlow(params: LoginParams?) {
|
||||
matrixSessionCache.removeAll()
|
||||
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
||||
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
|
||||
}
|
||||
|
||||
private fun switchToSignedOutFlow(sessionId: SessionId) {
|
||||
|
|
@ -175,7 +179,9 @@ class RootFlowNode @AssistedInject constructor(
|
|||
data object SplashScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object NotLoggedInFlow : NavTarget
|
||||
data class NotLoggedInFlow(
|
||||
val params: LoginParams?
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class LoggedInFlow(
|
||||
|
|
@ -211,13 +217,16 @@ class RootFlowNode @AssistedInject constructor(
|
|||
}
|
||||
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
}
|
||||
NavTarget.NotLoggedInFlow -> {
|
||||
is NavTarget.NotLoggedInFlow -> {
|
||||
val callback = object : NotLoggedInFlowNode.Callback {
|
||||
override fun onOpenBugReport() {
|
||||
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 -> {
|
||||
signedOutEntryPoint.nodeBuilder(this, buildContext)
|
||||
|
|
@ -272,18 +281,36 @@ class RootFlowNode @AssistedInject constructor(
|
|||
val resolvedIntent = intentResolver.resolve(intent) ?: return
|
||||
when (resolvedIntent) {
|
||||
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
|
||||
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
|
||||
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
|
||||
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
|
||||
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) {
|
||||
// Is there a session already?
|
||||
val latestSessionId = authenticationService.getLatestSessionId()
|
||||
if (latestSessionId == null) {
|
||||
// No session, open login
|
||||
switchToNotLoggedInFlow()
|
||||
switchToNotLoggedInFlow(null)
|
||||
} else {
|
||||
attachSession(latestSessionId)
|
||||
.attachIncomingShare(intent)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
package io.element.android.appnav.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.DeeplinkParser
|
||||
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 Oidc(val oidcAction: OidcAction) : ResolvedIntent
|
||||
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
|
||||
data class Login(val params: LoginParams) : ResolvedIntent
|
||||
data class IncomingShare(val intent: Intent) : ResolvedIntent
|
||||
}
|
||||
|
||||
class IntentResolver @Inject constructor(
|
||||
private val deeplinkParser: DeeplinkParser,
|
||||
private val loginIntentResolver: LoginIntentResolver,
|
||||
private val oidcIntentResolver: OidcIntentResolver,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) {
|
||||
|
|
@ -40,10 +44,17 @@ class IntentResolver @Inject constructor(
|
|||
val oidcAction = oidcIntentResolver.resolve(intent)
|
||||
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
|
||||
|
||||
// External link clicked? (matrix.to, element.io, etc.)
|
||||
val permalinkData = intent
|
||||
val actionViewData = intent
|
||||
.takeIf { it.action == Intent.ACTION_VIEW }
|
||||
?.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) }
|
||||
?.takeIf { it !is PermalinkData.FallbackLink }
|
||||
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import android.content.Intent
|
|||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
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.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||
|
|
@ -165,6 +167,7 @@ class IntentResolverTest {
|
|||
userId = UserId("@alice:matrix.org")
|
||||
)
|
||||
val sut = createIntentResolver(
|
||||
loginIntentResolverResult = { null },
|
||||
permalinkParserResult = { permalinkData }
|
||||
)
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
|
|
@ -182,7 +185,8 @@ class IntentResolverTest {
|
|||
@Test
|
||||
fun `test resolve external permalink, FallbackLink should be ignored`() {
|
||||
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 {
|
||||
action = Intent.ACTION_VIEW
|
||||
|
|
@ -231,7 +235,8 @@ class IntentResolverTest {
|
|||
@Test
|
||||
fun `test resolve invalid`() {
|
||||
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 {
|
||||
action = Intent.ACTION_VIEW
|
||||
|
|
@ -241,11 +246,29 @@ class IntentResolverTest {
|
|||
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(
|
||||
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
|
||||
permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
|
||||
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
|
||||
): IntentResolver {
|
||||
return IntentResolver(
|
||||
deeplinkParser = DeeplinkParser(),
|
||||
loginIntentResolver = FakeLoginIntentResolver(
|
||||
parseResult = loginIntentResolverResult,
|
||||
),
|
||||
oidcIntentResolver = DefaultOidcIntentResolver(
|
||||
oidcUrlParser = DefaultOidcUrlParser(
|
||||
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue