Skip to content

Commit 5a2aeac

Browse files
bmartyElementBot
andauthored
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>
1 parent 60c4155 commit 5a2aeac

File tree

52 files changed

+1092
-363
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1092
-363
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,17 @@
3434
android:value='androidx.startup' />
3535
</provider>
3636

37+
<!--
38+
Using launchMode singleTask to avoid multiple instances of the Activity
39+
when the app is already open. This is important for incoming share (see
40+
https://github.com/element-hq/element-x-android/issues/4074) and for opening
41+
the application from a mobile.element.io link.
42+
-->
3743
<activity
3844
android:name=".MainActivity"
3945
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
4046
android:exported="true"
41-
android:launchMode="singleTop"
47+
android:launchMode="singleTask"
4248
android:theme="@style/Theme.ElementX.Splash"
4349
android:windowSoftInputMode="adjustResize">
4450
<intent-filter>
@@ -54,6 +60,9 @@
5460
android:host="open"
5561
android:scheme="elementx" />
5662
</intent-filter>
63+
<!--
64+
Oidc redirection
65+
-->
5766
<intent-filter>
5867
<action android:name="android.intent.action.VIEW" />
5968

@@ -80,6 +89,21 @@
8089
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
8190
<data android:host="staging.element.io" />
8291
</intent-filter>
92+
<!--
93+
Element mobile links
94+
Example: https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org
95+
-->
96+
<intent-filter android:autoVerify="true">
97+
<action android:name="android.intent.action.VIEW" />
98+
99+
<category android:name="android.intent.category.DEFAULT" />
100+
<category android:name="android.intent.category.BROWSABLE" />
101+
102+
<data android:scheme="https" />
103+
<!-- Matching asset file: https://mobile.element.io/.well-known/assetlinks.json -->
104+
<data android:host="mobile.element.io" />
105+
<data android:path="/element" />
106+
</intent-filter>
83107
<!--
84108
matrix.to links
85109
Note: On Android 12 and higher clicking a web link (that is not an Android App Link) always shows content in a web browser

appnav/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ dependencies {
5353
testImplementation(libs.molecule.runtime)
5454
testImplementation(libs.test.truth)
5555
testImplementation(libs.test.turbine)
56+
testImplementation(projects.features.login.test)
5657
testImplementation(projects.libraries.matrix.test)
5758
testImplementation(projects.libraries.oidc.impl)
5859
testImplementation(projects.libraries.preferences.test)

appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ import dagger.assisted.Assisted
2424
import dagger.assisted.AssistedInject
2525
import io.element.android.anvilannotations.ContributesNode
2626
import io.element.android.features.login.api.LoginEntryPoint
27+
import io.element.android.features.login.api.LoginParams
2728
import io.element.android.libraries.architecture.BackstackView
2829
import io.element.android.libraries.architecture.BaseFlowNode
30+
import io.element.android.libraries.architecture.NodeInputs
31+
import io.element.android.libraries.architecture.inputs
2932
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
3033
import io.element.android.libraries.designsystem.utils.ScreenOrientation
3134
import io.element.android.libraries.di.AppScope
@@ -46,10 +49,16 @@ class NotLoggedInFlowNode @AssistedInject constructor(
4649
buildContext = buildContext,
4750
plugins = plugins,
4851
) {
52+
data class Params(
53+
val loginParams: LoginParams?,
54+
) : NodeInputs
55+
4956
interface Callback : Plugin {
5057
fun onOpenBugReport()
5158
}
5259

60+
private val inputs = inputs<Params>()
61+
5362
override fun onBuilt() {
5463
super.onBuilt()
5564
lifecycle.subscribe(
@@ -74,6 +83,12 @@ class NotLoggedInFlowNode @AssistedInject constructor(
7483
}
7584
loginEntryPoint
7685
.nodeBuilder(this, buildContext)
86+
.params(
87+
LoginEntryPoint.Params(
88+
accountProvider = inputs.loginParams?.accountProvider,
89+
loginHint = inputs.loginParams?.loginHint,
90+
)
91+
)
7792
.callback(callback)
7893
.build()
7994
}

appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ import io.element.android.appnav.intent.ResolvedIntent
3333
import io.element.android.appnav.root.RootNavStateFlowFactory
3434
import io.element.android.appnav.root.RootPresenter
3535
import io.element.android.appnav.root.RootView
36+
import io.element.android.features.enterprise.api.EnterpriseService
37+
import io.element.android.features.login.api.LoginParams
3638
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
3739
import io.element.android.features.signedout.api.SignedOutEntryPoint
3840
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
3941
import io.element.android.libraries.architecture.BackstackView
4042
import io.element.android.libraries.architecture.BaseFlowNode
4143
import io.element.android.libraries.architecture.createNode
4244
import io.element.android.libraries.architecture.waitForChildAttached
45+
import io.element.android.libraries.core.uri.ensureProtocol
4346
import io.element.android.libraries.deeplink.DeeplinkData
4447
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
4548
import io.element.android.libraries.di.AppScope
@@ -61,6 +64,7 @@ class RootFlowNode @AssistedInject constructor(
6164
@Assisted val buildContext: BuildContext,
6265
@Assisted plugins: List<Plugin>,
6366
private val authenticationService: MatrixAuthenticationService,
67+
private val enterpriseService: EnterpriseService,
6468
private val navStateFlowFactory: RootNavStateFlowFactory,
6569
private val matrixSessionCache: MatrixSessionCache,
6670
private val presenter: RootPresenter,
@@ -99,14 +103,14 @@ class RootFlowNode @AssistedInject constructor(
99103
if (navState.loggedInState.isTokenValid) {
100104
tryToRestoreLatestSession(
101105
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
102-
onFailure = { switchToNotLoggedInFlow() }
106+
onFailure = { switchToNotLoggedInFlow(null) }
103107
)
104108
} else {
105109
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
106110
}
107111
}
108112
LoggedInState.NotLoggedIn -> {
109-
switchToNotLoggedInFlow()
113+
switchToNotLoggedInFlow(null)
110114
}
111115
}
112116
}
@@ -117,9 +121,9 @@ class RootFlowNode @AssistedInject constructor(
117121
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
118122
}
119123

120-
private fun switchToNotLoggedInFlow() {
124+
private fun switchToNotLoggedInFlow(params: LoginParams?) {
121125
matrixSessionCache.removeAll()
122-
backstack.safeRoot(NavTarget.NotLoggedInFlow)
126+
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
123127
}
124128

125129
private fun switchToSignedOutFlow(sessionId: SessionId) {
@@ -175,7 +179,9 @@ class RootFlowNode @AssistedInject constructor(
175179
data object SplashScreen : NavTarget
176180

177181
@Parcelize
178-
data object NotLoggedInFlow : NavTarget
182+
data class NotLoggedInFlow(
183+
val params: LoginParams?
184+
) : NavTarget
179185

180186
@Parcelize
181187
data class LoggedInFlow(
@@ -211,13 +217,16 @@ class RootFlowNode @AssistedInject constructor(
211217
}
212218
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
213219
}
214-
NavTarget.NotLoggedInFlow -> {
220+
is NavTarget.NotLoggedInFlow -> {
215221
val callback = object : NotLoggedInFlowNode.Callback {
216222
override fun onOpenBugReport() {
217223
backstack.push(NavTarget.BugReport)
218224
}
219225
}
220-
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(callback))
226+
val params = NotLoggedInFlowNode.Params(
227+
loginParams = navTarget.params,
228+
)
229+
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
221230
}
222231
is NavTarget.SignedOutFlow -> {
223232
signedOutEntryPoint.nodeBuilder(this, buildContext)
@@ -272,18 +281,36 @@ class RootFlowNode @AssistedInject constructor(
272281
val resolvedIntent = intentResolver.resolve(intent) ?: return
273282
when (resolvedIntent) {
274283
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
284+
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
275285
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
276286
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
277287
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
278288
}
279289
}
280290

291+
private suspend fun onLoginLink(params: LoginParams) {
292+
// Is there a session already?
293+
val latestSessionId = authenticationService.getLatestSessionId()
294+
if (latestSessionId == null) {
295+
// No session, open login
296+
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
297+
switchToNotLoggedInFlow(params)
298+
} else {
299+
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
300+
switchToNotLoggedInFlow(null)
301+
}
302+
} else {
303+
// Just ignore the login link if we already have a session
304+
Timber.w("Login link ignored, we already have a session")
305+
}
306+
}
307+
281308
private suspend fun onIncomingShare(intent: Intent) {
282309
// Is there a session already?
283310
val latestSessionId = authenticationService.getLatestSessionId()
284311
if (latestSessionId == null) {
285312
// No session, open login
286-
switchToNotLoggedInFlow()
313+
switchToNotLoggedInFlow(null)
287314
} else {
288315
attachSession(latestSessionId)
289316
.attachIncomingShare(intent)

appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
package io.element.android.appnav.intent
99

1010
import android.content.Intent
11+
import io.element.android.features.login.api.LoginIntentResolver
12+
import io.element.android.features.login.api.LoginParams
1113
import io.element.android.libraries.deeplink.DeeplinkData
1214
import io.element.android.libraries.deeplink.DeeplinkParser
1315
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -21,11 +23,13 @@ sealed interface ResolvedIntent {
2123
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
2224
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
2325
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
26+
data class Login(val params: LoginParams) : ResolvedIntent
2427
data class IncomingShare(val intent: Intent) : ResolvedIntent
2528
}
2629

2730
class IntentResolver @Inject constructor(
2831
private val deeplinkParser: DeeplinkParser,
32+
private val loginIntentResolver: LoginIntentResolver,
2933
private val oidcIntentResolver: OidcIntentResolver,
3034
private val permalinkParser: PermalinkParser,
3135
) {
@@ -40,10 +44,17 @@ class IntentResolver @Inject constructor(
4044
val oidcAction = oidcIntentResolver.resolve(intent)
4145
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
4246

43-
// External link clicked? (matrix.to, element.io, etc.)
44-
val permalinkData = intent
47+
val actionViewData = intent
4548
.takeIf { it.action == Intent.ACTION_VIEW }
4649
?.dataString
50+
51+
// Mobile configuration link clicked? (mobile.element.io)
52+
val mobileLoginData = actionViewData
53+
?.let { loginIntentResolver.parse(it) }
54+
if (mobileLoginData != null) return ResolvedIntent.Login(mobileLoginData)
55+
56+
// External link clicked? (matrix.to, element.io, etc.)
57+
val permalinkData = actionViewData
4758
?.let { permalinkParser.parse(it) }
4859
?.takeIf { it !is PermalinkData.FallbackLink }
4960
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)

appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import android.content.Intent
1212
import android.net.Uri
1313
import androidx.core.net.toUri
1414
import com.google.common.truth.Truth.assertThat
15+
import io.element.android.features.login.api.LoginParams
16+
import io.element.android.features.login.test.FakeLoginIntentResolver
1517
import io.element.android.libraries.deeplink.DeepLinkCreator
1618
import io.element.android.libraries.deeplink.DeeplinkData
1719
import io.element.android.libraries.deeplink.DeeplinkParser
@@ -165,6 +167,7 @@ class IntentResolverTest {
165167
userId = UserId("@alice:matrix.org")
166168
)
167169
val sut = createIntentResolver(
170+
loginIntentResolverResult = { null },
168171
permalinkParserResult = { permalinkData }
169172
)
170173
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@@ -182,7 +185,8 @@ class IntentResolverTest {
182185
@Test
183186
fun `test resolve external permalink, FallbackLink should be ignored`() {
184187
val sut = createIntentResolver(
185-
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
188+
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
189+
loginIntentResolverResult = { null },
186190
)
187191
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
188192
action = Intent.ACTION_VIEW
@@ -231,7 +235,8 @@ class IntentResolverTest {
231235
@Test
232236
fun `test resolve invalid`() {
233237
val sut = createIntentResolver(
234-
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
238+
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
239+
loginIntentResolverResult = { null },
235240
)
236241
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
237242
action = Intent.ACTION_VIEW
@@ -241,11 +246,29 @@ class IntentResolverTest {
241246
assertThat(result).isNull()
242247
}
243248

249+
@Test
250+
fun `test resolve login param`() {
251+
val aLoginParams = LoginParams("accountProvider", null)
252+
val sut = createIntentResolver(
253+
loginIntentResolverResult = { aLoginParams },
254+
)
255+
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
256+
action = Intent.ACTION_VIEW
257+
data = "".toUri()
258+
}
259+
val result = sut.resolve(intent)
260+
assertThat(result).isEqualTo(ResolvedIntent.Login(aLoginParams))
261+
}
262+
244263
private fun createIntentResolver(
245-
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
264+
permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
265+
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
246266
): IntentResolver {
247267
return IntentResolver(
248268
deeplinkParser = DeeplinkParser(),
269+
loginIntentResolver = FakeLoginIntentResolver(
270+
parseResult = loginIntentResolverResult,
271+
),
249272
oidcIntentResolver = DefaultOidcIntentResolver(
250273
oidcUrlParser = DefaultOidcUrlParser(
251274
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),

features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ import com.bumble.appyx.core.plugin.Plugin
1313
import io.element.android.libraries.architecture.FeatureEntryPoint
1414

1515
interface LoginEntryPoint : FeatureEntryPoint {
16+
data class Params(
17+
val accountProvider: String?,
18+
val loginHint: String?,
19+
)
20+
1621
interface Callback : Plugin {
1722
fun onReportProblem()
1823
}
1924

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

2227
interface NodeBuilder {
28+
fun params(params: Params): NodeBuilder
2329
fun callback(callback: Callback): NodeBuilder
2430
fun build(): Node
2531
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.login.api
9+
10+
interface LoginIntentResolver {
11+
fun parse(uriString: String): LoginParams?
12+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.login.api
9+
10+
import android.os.Parcelable
11+
import kotlinx.parcelize.Parcelize
12+
13+
/**
14+
* Parameters to start the login flow, when the application is opened
15+
* from a mobile.element.io link.
16+
*/
17+
@Parcelize
18+
data class LoginParams(
19+
val accountProvider: String,
20+
val loginHint: String?
21+
) : Parcelable

features/login/impl/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ dependencies {
5858
testImplementation(libs.test.robolectric)
5959
testImplementation(libs.test.truth)
6060
testImplementation(libs.test.turbine)
61+
testImplementation(projects.features.login.test)
6162
testImplementation(projects.features.enterprise.test)
6263
testImplementation(projects.libraries.featureflag.test)
6364
testImplementation(projects.libraries.matrix.test)

0 commit comments

Comments
 (0)