Skip to content

Commit 1afcce2

Browse files
authored
Merge pull request #5126 from element-hq/feature/bma/redirectToElementPro
Redirect FOSS user to Element Pro according to element .well-known file
2 parents 5f7b3b6 + c18a1e7 commit 1afcce2

File tree

40 files changed

+657
-45
lines changed

40 files changed

+657
-45
lines changed

appnav/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies {
3636
implementation(projects.libraries.designsystem)
3737
implementation(projects.libraries.matrixui)
3838
implementation(projects.libraries.uiStrings)
39+
implementation(projects.features.login.api)
3940

4041
implementation(libs.coil)
4142

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ 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
3736
import io.element.android.features.login.api.LoginParams
37+
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
3838
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
3939
import io.element.android.features.rageshake.api.reporter.BugReporter
4040
import io.element.android.features.signedout.api.SignedOutEntryPoint
@@ -65,7 +65,7 @@ class RootFlowNode @AssistedInject constructor(
6565
@Assisted val buildContext: BuildContext,
6666
@Assisted plugins: List<Plugin>,
6767
private val authenticationService: MatrixAuthenticationService,
68-
private val enterpriseService: EnterpriseService,
68+
private val accountProviderAccessControl: AccountProviderAccessControl,
6969
private val navStateFlowFactory: RootNavStateFlowFactory,
7070
private val matrixSessionCache: MatrixSessionCache,
7171
private val presenter: RootPresenter,
@@ -296,7 +296,7 @@ class RootFlowNode @AssistedInject constructor(
296296
val latestSessionId = authenticationService.getLatestSessionId()
297297
if (latestSessionId == null) {
298298
// No session, open login
299-
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
299+
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
300300
switchToNotLoggedInFlow(params)
301301
} else {
302302
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
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.accesscontrol
9+
10+
interface AccountProviderAccessControl {
11+
suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean
12+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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.impl.accesscontrol
9+
10+
import com.squareup.anvil.annotations.ContributesBinding
11+
import io.element.android.features.enterprise.api.EnterpriseService
12+
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
13+
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
14+
import io.element.android.libraries.core.uri.ensureProtocol
15+
import io.element.android.libraries.di.AppScope
16+
import javax.inject.Inject
17+
18+
@ContributesBinding(AppScope::class)
19+
class DefaultAccountProviderAccessControl @Inject constructor(
20+
private val enterpriseService: EnterpriseService,
21+
private val elementWellknownRetriever: ElementWellknownRetriever,
22+
) : AccountProviderAccessControl {
23+
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
24+
assertIsAllowedToConnectToAccountProvider(
25+
title = accountProviderUrl,
26+
accountProviderUrl = accountProviderUrl,
27+
)
28+
true
29+
} catch (_: AccountProviderAccessException) {
30+
false
31+
}
32+
33+
@Throws(AccountProviderAccessException::class)
34+
suspend fun assertIsAllowedToConnectToAccountProvider(
35+
title: String,
36+
accountProviderUrl: String,
37+
) {
38+
if (enterpriseService.isEnterpriseBuild.not()) {
39+
// Ensure that Element Pro is not required for this account provider
40+
val wellKnown = elementWellknownRetriever.retrieve(
41+
accountProviderUrl = accountProviderUrl.ensureProtocol(),
42+
)
43+
if (wellKnown?.enforceElementPro == true) {
44+
throw AccountProviderAccessException.NeedElementProException(
45+
unauthorisedAccountProviderTitle = title,
46+
applicationId = ELEMENT_PRO_APPLICATION_ID,
47+
)
48+
}
49+
}
50+
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
51+
throw AccountProviderAccessException.UnauthorizedAccountProviderException(
52+
unauthorisedAccountProviderTitle = title,
53+
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
54+
)
55+
}
56+
}
57+
58+
companion object {
59+
const val ELEMENT_PRO_APPLICATION_ID = "io.element.enterprise"
60+
}
61+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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.impl.accesscontrol
9+
10+
import com.squareup.anvil.annotations.ContributesBinding
11+
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
12+
import io.element.android.features.login.impl.resolver.network.WellknownAPI
13+
import io.element.android.libraries.di.AppScope
14+
import io.element.android.libraries.network.RetrofitFactory
15+
import timber.log.Timber
16+
import javax.inject.Inject
17+
18+
interface ElementWellknownRetriever {
19+
suspend fun retrieve(accountProviderUrl: String): ElementWellKnown?
20+
}
21+
22+
@ContributesBinding(AppScope::class)
23+
class DefaultElementWellknownRetriever @Inject constructor(
24+
private val retrofitFactory: RetrofitFactory,
25+
) : ElementWellknownRetriever {
26+
override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? {
27+
val wellknownApi = try {
28+
retrofitFactory.create(accountProviderUrl)
29+
.create(WellknownAPI::class.java)
30+
} catch (e: Exception) {
31+
// If the base URL is not valid, we cannot retrieve the well-known data
32+
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
33+
return null
34+
}
35+
return try {
36+
wellknownApi.getElementWellKnown()
37+
} catch (e: Exception) {
38+
Timber.e(e, "Failed to retrieve Element well-known data for $accountProviderUrl")
39+
null
40+
}
41+
}
42+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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.impl.changeserver
9+
10+
sealed class AccountProviderAccessException : Exception() {
11+
data class NeedElementProException(
12+
val unauthorisedAccountProviderTitle: String,
13+
val applicationId: String,
14+
) : AccountProviderAccessException()
15+
16+
data class UnauthorizedAccountProviderException(
17+
val unauthorisedAccountProviderTitle: String,
18+
val authorisedAccountProviderTitles: List<String>,
19+
) : AccountProviderAccessException()
20+
}

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import androidx.compose.runtime.MutableState
1212
import androidx.compose.runtime.mutableStateOf
1313
import androidx.compose.runtime.remember
1414
import androidx.compose.runtime.rememberCoroutineScope
15-
import io.element.android.features.enterprise.api.EnterpriseService
15+
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
1616
import io.element.android.features.login.impl.accountprovider.AccountProvider
1717
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
1818
import io.element.android.features.login.impl.error.ChangeServerError
@@ -27,7 +27,7 @@ import javax.inject.Inject
2727
class ChangeServerPresenter @Inject constructor(
2828
private val authenticationService: MatrixAuthenticationService,
2929
private val accountProviderDataSource: AccountProviderDataSource,
30-
private val enterpriseService: EnterpriseService,
30+
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
3131
) : Presenter<ChangeServerState> {
3232
@Composable
3333
override fun present(): ChangeServerState {
@@ -55,12 +55,10 @@ class ChangeServerPresenter @Inject constructor(
5555
changeServerAction: MutableState<AsyncData<Unit>>,
5656
) = launch {
5757
suspend {
58-
if (enterpriseService.isAllowedToConnectToHomeserver(data.url).not()) {
59-
throw UnauthorizedAccountProviderException(
60-
unauthorisedAccountProviderTitle = data.title,
61-
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
62-
)
63-
}
58+
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
59+
title = data.title,
60+
accountProviderUrl = data.url,
61+
)
6462
authenticationService.setHomeserver(data.url).map {
6563
authenticationService.getHomeserverDetails().value!!
6664
// Valid, remember user choice

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
2626
)
2727
)
2828
),
29+
aChangeServerState(
30+
changeServerAction = AsyncData.Failure(
31+
ChangeServerError.NeedElementPro(
32+
unauthorisedAccountProviderTitle = "example.com",
33+
applicationId = "applicationId",
34+
),
35+
)
36+
),
2937
)
3038
}
3139

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ import androidx.compose.runtime.LaunchedEffect
1212
import androidx.compose.runtime.getValue
1313
import androidx.compose.runtime.rememberUpdatedState
1414
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.platform.LocalContext
1516
import androidx.compose.ui.res.stringResource
1617
import androidx.compose.ui.tooling.preview.PreviewParameter
1718
import io.element.android.features.login.impl.R
1819
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
1920
import io.element.android.features.login.impl.error.ChangeServerError
21+
import io.element.android.libraries.androidutils.system.openGooglePlay
2022
import io.element.android.libraries.architecture.AsyncData
2123
import io.element.android.libraries.designsystem.components.ProgressDialog
24+
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
2225
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
2326
import io.element.android.libraries.designsystem.preview.ElementPreview
2427
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -31,6 +34,7 @@ fun ChangeServerView(
3134
onSuccess: () -> Unit,
3235
modifier: Modifier = Modifier,
3336
) {
37+
val context = LocalContext.current
3438
val eventSink = state.eventSink
3539
when (state.changeServerAction) {
3640
is AsyncData.Failure -> {
@@ -56,6 +60,24 @@ fun ChangeServerView(
5660
}
5761
)
5862
}
63+
is ChangeServerError.NeedElementPro -> {
64+
ConfirmationDialog(
65+
modifier = modifier,
66+
title = stringResource(R.string.screen_change_server_error_element_pro_required_title),
67+
content = stringResource(
68+
R.string.screen_change_server_error_element_pro_required_message,
69+
error.unauthorisedAccountProviderTitle,
70+
),
71+
submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android),
72+
onSubmitClick = {
73+
context.openGooglePlay(error.applicationId)
74+
eventSink.invoke(ChangeServerEvents.ClearError)
75+
},
76+
onDismiss = {
77+
eventSink.invoke(ChangeServerEvents.ClearError)
78+
},
79+
)
80+
}
5981
is ChangeServerError.UnauthorizedAccountProvider -> {
6082
ErrorDialog(
6183
modifier = modifier,

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)