Skip to content

Commit 89ef890

Browse files
committed
Prevent users from using Element FOSS on homeservers that enforce the usage of Element Pro.
1 parent bfdcc97 commit 89ef890

File tree

24 files changed

+556
-36
lines changed

24 files changed

+556
-36
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.signedout.api.SignedOutEntryPoint
4040
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
@@ -64,7 +64,7 @@ class RootFlowNode @AssistedInject constructor(
6464
@Assisted val buildContext: BuildContext,
6565
@Assisted plugins: List<Plugin>,
6666
private val authenticationService: MatrixAuthenticationService,
67-
private val enterpriseService: EnterpriseService,
67+
private val accountProviderAccessControl: AccountProviderAccessControl,
6868
private val navStateFlowFactory: RootNavStateFlowFactory,
6969
private val matrixSessionCache: MatrixSessionCache,
7070
private val presenter: RootPresenter,
@@ -293,7 +293,7 @@ class RootFlowNode @AssistedInject constructor(
293293
val latestSessionId = authenticationService.getLatestSessionId()
294294
if (latestSessionId == null) {
295295
// No session, open login
296-
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
296+
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
297297
switchToNotLoggedInFlow(params)
298298
} else {
299299
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+
}

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: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77

88
package io.element.android.features.login.impl.changeserver
99

10-
class UnauthorizedAccountProviderException(
11-
val unauthorisedAccountProviderTitle: String,
12-
val authorisedAccountProviderTitles: List<String>,
13-
) : Exception()
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/error/ChangeServerError.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable
1212
import androidx.compose.runtime.ReadOnlyComposable
1313
import androidx.compose.ui.res.stringResource
1414
import io.element.android.features.login.impl.R
15-
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
15+
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
1616
import io.element.android.libraries.matrix.api.auth.AuthenticationException
1717
import io.element.android.libraries.ui.strings.CommonStrings
1818

19-
sealed class ChangeServerError : Throwable() {
19+
sealed class ChangeServerError : Exception() {
2020
data class Error(
2121
@StringRes val messageId: Int? = null,
2222
val messageStr: String? = null,
@@ -26,6 +26,11 @@ sealed class ChangeServerError : Throwable() {
2626
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
2727
}
2828

29+
data class NeedElementPro(
30+
val unauthorisedAccountProviderTitle: String,
31+
val applicationId: String,
32+
) : ChangeServerError()
33+
2934
data class UnauthorizedAccountProvider(
3035
val unauthorisedAccountProviderTitle: String,
3136
val authorisedAccountProviderTitles: List<String>,
@@ -37,7 +42,11 @@ sealed class ChangeServerError : Throwable() {
3742
fun from(error: Throwable): ChangeServerError = when (error) {
3843
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
3944
is AuthenticationException.Oidc -> Error(messageStr = error.message)
40-
is UnauthorizedAccountProviderException -> UnauthorizedAccountProvider(
45+
is AccountProviderAccessException.NeedElementProException -> NeedElementPro(
46+
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
47+
applicationId = error.applicationId,
48+
)
49+
is AccountProviderAccessException.UnauthorizedAccountProviderException -> UnauthorizedAccountProvider(
4150
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
4251
authorisedAccountProviderTitles = error.authorisedAccountProviderTitles,
4352
)

0 commit comments

Comments
 (0)