Skip to content

Add support for login link #4752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,17 @@
android:value='androidx.startup' />
</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
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
Expand All @@ -54,6 +60,9 @@
android:host="open"
android:scheme="elementx" />
</intent-filter>
<!--
Oidc redirection
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

Expand All @@ -80,6 +89,21 @@
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
<data android:host="staging.element.io" />
</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
Note: On Android 12 and higher clicking a web link (that is not an Android App Link) always shows content in a web browser
Expand Down
1 change: 1 addition & 0 deletions appnav/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.impl)
testImplementation(projects.libraries.preferences.test)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ 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
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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should try to give some feedback to the user in this case, although I guess there's no easy way to display a dialog/toast from here 🫤 .

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. Showing a dialog is not in the flow. If one day we have multi account, we could be able to handle the link in all cases.

image

}
}

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
) {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@
import io.element.android.libraries.architecture.FeatureEntryPoint

interface LoginEntryPoint : FeatureEntryPoint {
data class Params(
val accountProvider: String?,
val loginHint: String?,

Check warning on line 18 in features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt

View check run for this annotation

Codecov / codecov/patch

features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt#L16-L18

Added lines #L16 - L18 were not covered by tests
)

interface Callback : Plugin {
fun onReportProblem()
}

fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder

interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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?
}
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 17 in features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt

View check run for this annotation

Codecov / codecov/patch

features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt#L17

Added line #L17 was not covered by tests
data class LoginParams(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add some docs for what these are used for?

val accountProvider: String,
val loginHint: String?
) : Parcelable

Check warning on line 21 in features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt

View check run for this annotation

Codecov / codecov/patch

features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt#L21

Added line #L21 was not covered by tests
1 change: 1 addition & 0 deletions features/login/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
Expand Down
Loading
Loading