Merge pull request #5126 from element-hq/feature/bma/redirectToElementPro
Redirect FOSS user to Element Pro according to element .well-known file
This commit is contained in:
commit
b1a48ec863
40 changed files with 657 additions and 45 deletions
|
|
@ -36,6 +36,7 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.features.login.api)
|
||||||
|
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ import io.element.android.appnav.intent.ResolvedIntent
|
||||||
import io.element.android.appnav.root.RootNavStateFlowFactory
|
import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||||
import io.element.android.appnav.root.RootPresenter
|
import io.element.android.appnav.root.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
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.login.api.LoginParams
|
||||||
|
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||||
|
|
@ -65,7 +65,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
@Assisted val buildContext: BuildContext,
|
@Assisted val buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val accountProviderAccessControl: AccountProviderAccessControl,
|
||||||
private val navStateFlowFactory: RootNavStateFlowFactory,
|
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||||
private val matrixSessionCache: MatrixSessionCache,
|
private val matrixSessionCache: MatrixSessionCache,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
|
|
@ -296,7 +296,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
val latestSessionId = authenticationService.getLatestSessionId()
|
val latestSessionId = authenticationService.getLatestSessionId()
|
||||||
if (latestSessionId == null) {
|
if (latestSessionId == null) {
|
||||||
// No session, open login
|
// No session, open login
|
||||||
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
|
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
||||||
switchToNotLoggedInFlow(params)
|
switchToNotLoggedInFlow(params)
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
|
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
|
||||||
|
|
|
||||||
|
|
@ -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.accesscontrol
|
||||||
|
|
||||||
|
interface AccountProviderAccessControl {
|
||||||
|
suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.accesscontrol
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
|
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultAccountProviderAccessControl @Inject constructor(
|
||||||
|
private val enterpriseService: EnterpriseService,
|
||||||
|
private val elementWellknownRetriever: ElementWellknownRetriever,
|
||||||
|
) : AccountProviderAccessControl {
|
||||||
|
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
|
||||||
|
assertIsAllowedToConnectToAccountProvider(
|
||||||
|
title = accountProviderUrl,
|
||||||
|
accountProviderUrl = accountProviderUrl,
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (_: AccountProviderAccessException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(AccountProviderAccessException::class)
|
||||||
|
suspend fun assertIsAllowedToConnectToAccountProvider(
|
||||||
|
title: String,
|
||||||
|
accountProviderUrl: String,
|
||||||
|
) {
|
||||||
|
if (enterpriseService.isEnterpriseBuild.not()) {
|
||||||
|
// Ensure that Element Pro is not required for this account provider
|
||||||
|
val wellKnown = elementWellknownRetriever.retrieve(
|
||||||
|
accountProviderUrl = accountProviderUrl.ensureProtocol(),
|
||||||
|
)
|
||||||
|
if (wellKnown?.enforceElementPro == true) {
|
||||||
|
throw AccountProviderAccessException.NeedElementProException(
|
||||||
|
unauthorisedAccountProviderTitle = title,
|
||||||
|
applicationId = ELEMENT_PRO_APPLICATION_ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
|
||||||
|
throw AccountProviderAccessException.UnauthorizedAccountProviderException(
|
||||||
|
unauthorisedAccountProviderTitle = title,
|
||||||
|
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ELEMENT_PRO_APPLICATION_ID = "io.element.enterprise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.accesscontrol
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
|
||||||
|
import io.element.android.features.login.impl.resolver.network.WellknownAPI
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.network.RetrofitFactory
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface ElementWellknownRetriever {
|
||||||
|
suspend fun retrieve(accountProviderUrl: String): ElementWellKnown?
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultElementWellknownRetriever @Inject constructor(
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
) : ElementWellknownRetriever {
|
||||||
|
override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? {
|
||||||
|
val wellknownApi = try {
|
||||||
|
retrofitFactory.create(accountProviderUrl)
|
||||||
|
.create(WellknownAPI::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If the base URL is not valid, we cannot retrieve the well-known data
|
||||||
|
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
wellknownApi.getElementWellKnown()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to retrieve Element well-known data for $accountProviderUrl")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.changeserver
|
||||||
|
|
||||||
|
sealed class AccountProviderAccessException : Exception() {
|
||||||
|
data class NeedElementProException(
|
||||||
|
val unauthorisedAccountProviderTitle: String,
|
||||||
|
val applicationId: String,
|
||||||
|
) : AccountProviderAccessException()
|
||||||
|
|
||||||
|
data class UnauthorizedAccountProviderException(
|
||||||
|
val unauthorisedAccountProviderTitle: String,
|
||||||
|
val authorisedAccountProviderTitles: List<String>,
|
||||||
|
) : AccountProviderAccessException()
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
|
@ -27,7 +27,7 @@ import javax.inject.Inject
|
||||||
class ChangeServerPresenter @Inject constructor(
|
class ChangeServerPresenter @Inject constructor(
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||||
) : Presenter<ChangeServerState> {
|
) : Presenter<ChangeServerState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ChangeServerState {
|
override fun present(): ChangeServerState {
|
||||||
|
|
@ -55,12 +55,10 @@ class ChangeServerPresenter @Inject constructor(
|
||||||
changeServerAction: MutableState<AsyncData<Unit>>,
|
changeServerAction: MutableState<AsyncData<Unit>>,
|
||||||
) = launch {
|
) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
if (enterpriseService.isAllowedToConnectToHomeserver(data.url).not()) {
|
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
|
||||||
throw UnauthorizedAccountProviderException(
|
title = data.title,
|
||||||
unauthorisedAccountProviderTitle = data.title,
|
accountProviderUrl = data.url,
|
||||||
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
authenticationService.setHomeserver(data.url).map {
|
authenticationService.setHomeserver(data.url).map {
|
||||||
authenticationService.getHomeserverDetails().value!!
|
authenticationService.getHomeserverDetails().value!!
|
||||||
// Valid, remember user choice
|
// Valid, remember user choice
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
aChangeServerState(
|
||||||
|
changeServerAction = AsyncData.Failure(
|
||||||
|
ChangeServerError.NeedElementPro(
|
||||||
|
unauthorisedAccountProviderTitle = "example.com",
|
||||||
|
applicationId = "applicationId",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,16 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.libraries.androidutils.system.openGooglePlay
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -31,6 +34,7 @@ fun ChangeServerView(
|
||||||
onSuccess: () -> Unit,
|
onSuccess: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
when (state.changeServerAction) {
|
when (state.changeServerAction) {
|
||||||
is AsyncData.Failure -> {
|
is AsyncData.Failure -> {
|
||||||
|
|
@ -56,6 +60,24 @@ fun ChangeServerView(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is ChangeServerError.NeedElementPro -> {
|
||||||
|
ConfirmationDialog(
|
||||||
|
modifier = modifier,
|
||||||
|
title = stringResource(R.string.screen_change_server_error_element_pro_required_title),
|
||||||
|
content = stringResource(
|
||||||
|
R.string.screen_change_server_error_element_pro_required_message,
|
||||||
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
),
|
||||||
|
submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android),
|
||||||
|
onSubmitClick = {
|
||||||
|
context.openGooglePlay(error.applicationId)
|
||||||
|
eventSink.invoke(ChangeServerEvents.ClearError)
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
eventSink.invoke(ChangeServerEvents.ClearError)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
is ChangeServerError.UnauthorizedAccountProvider -> {
|
is ChangeServerError.UnauthorizedAccountProvider -> {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/*
|
|
||||||
* 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.impl.changeserver
|
|
||||||
|
|
||||||
class UnauthorizedAccountProviderException(
|
|
||||||
val unauthorisedAccountProviderTitle: String,
|
|
||||||
val authorisedAccountProviderTitles: List<String>,
|
|
||||||
) : Exception()
|
|
||||||
|
|
@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
sealed class ChangeServerError : Throwable() {
|
sealed class ChangeServerError : Exception() {
|
||||||
data class Error(
|
data class Error(
|
||||||
@StringRes val messageId: Int? = null,
|
@StringRes val messageId: Int? = null,
|
||||||
val messageStr: String? = null,
|
val messageStr: String? = null,
|
||||||
|
|
@ -26,6 +26,11 @@ sealed class ChangeServerError : Throwable() {
|
||||||
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
|
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NeedElementPro(
|
||||||
|
val unauthorisedAccountProviderTitle: String,
|
||||||
|
val applicationId: String,
|
||||||
|
) : ChangeServerError()
|
||||||
|
|
||||||
data class UnauthorizedAccountProvider(
|
data class UnauthorizedAccountProvider(
|
||||||
val unauthorisedAccountProviderTitle: String,
|
val unauthorisedAccountProviderTitle: String,
|
||||||
val authorisedAccountProviderTitles: List<String>,
|
val authorisedAccountProviderTitles: List<String>,
|
||||||
|
|
@ -37,7 +42,11 @@ sealed class ChangeServerError : Throwable() {
|
||||||
fun from(error: Throwable): ChangeServerError = when (error) {
|
fun from(error: Throwable): ChangeServerError = when (error) {
|
||||||
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
|
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
|
||||||
is AuthenticationException.Oidc -> Error(messageStr = error.message)
|
is AuthenticationException.Oidc -> Error(messageStr = error.message)
|
||||||
is UnauthorizedAccountProviderException -> UnauthorizedAccountProvider(
|
is AccountProviderAccessException.NeedElementProException -> NeedElementPro(
|
||||||
|
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
|
||||||
|
applicationId = error.applicationId,
|
||||||
|
)
|
||||||
|
is AccountProviderAccessException.UnauthorizedAccountProviderException -> UnauthorizedAccountProvider(
|
||||||
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
|
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
|
||||||
authorisedAccountProviderTitles = error.authorisedAccountProviderTitles,
|
authorisedAccountProviderTitles = error.authorisedAccountProviderTitles,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.error
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.login.impl.R
|
||||||
|
|
||||||
|
class ChangeServerErrorProvider : PreviewParameterProvider<ChangeServerError> {
|
||||||
|
override val values: Sequence<ChangeServerError>
|
||||||
|
get() = sequenceOf(
|
||||||
|
ChangeServerError.Error(
|
||||||
|
messageId = R.string.screen_change_server_error_invalid_homeserver,
|
||||||
|
),
|
||||||
|
ChangeServerError.Error(
|
||||||
|
messageStr = "An error description",
|
||||||
|
),
|
||||||
|
ChangeServerError.NeedElementPro(
|
||||||
|
unauthorisedAccountProviderTitle = "element.io",
|
||||||
|
applicationId = "io.element.enterprise",
|
||||||
|
),
|
||||||
|
ChangeServerError.UnauthorizedAccountProvider(
|
||||||
|
unauthorisedAccountProviderTitle = "element.io",
|
||||||
|
authorisedAccountProviderTitles = listOf("provider.org", "provider.io"),
|
||||||
|
),
|
||||||
|
ChangeServerError.SlidingSyncAlert,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,13 +8,20 @@
|
||||||
package io.element.android.features.login.impl.login
|
package io.element.android.features.login.impl.login
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
|
||||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
|
import io.element.android.libraries.androidutils.system.openGooglePlay
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -28,6 +35,7 @@ fun LoginModeView(
|
||||||
onNeedLoginPassword: () -> Unit,
|
onNeedLoginPassword: () -> Unit,
|
||||||
onCreateAccountContinue: (url: String) -> Unit
|
onCreateAccountContinue: (url: String) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
when (loginMode) {
|
when (loginMode) {
|
||||||
is AsyncData.Failure -> {
|
is AsyncData.Failure -> {
|
||||||
when (val error = loginMode.error) {
|
when (val error = loginMode.error) {
|
||||||
|
|
@ -48,6 +56,21 @@ fun LoginModeView(
|
||||||
onDismiss = onClearError,
|
onDismiss = onClearError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is ChangeServerError.NeedElementPro -> {
|
||||||
|
ConfirmationDialog(
|
||||||
|
title = stringResource(R.string.screen_change_server_error_element_pro_required_title),
|
||||||
|
content = stringResource(
|
||||||
|
R.string.screen_change_server_error_element_pro_required_message,
|
||||||
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
),
|
||||||
|
submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android),
|
||||||
|
onSubmitClick = {
|
||||||
|
context.openGooglePlay(error.applicationId)
|
||||||
|
onClearError()
|
||||||
|
},
|
||||||
|
onDismiss = onClearError,
|
||||||
|
)
|
||||||
|
}
|
||||||
is ChangeServerError.UnauthorizedAccountProvider -> {
|
is ChangeServerError.UnauthorizedAccountProvider -> {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
content = stringResource(
|
content = stringResource(
|
||||||
|
|
@ -87,3 +110,18 @@ fun LoginModeView(
|
||||||
AsyncData.Uninitialized -> Unit
|
AsyncData.Uninitialized -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) {
|
||||||
|
ElementPreview {
|
||||||
|
LoginModeView(
|
||||||
|
loginMode = AsyncData.Failure(error),
|
||||||
|
onClearError = {},
|
||||||
|
onLearnMoreClick = {},
|
||||||
|
onOidcDetails = {},
|
||||||
|
onNeedLoginPassword = {},
|
||||||
|
onCreateAccountContinue = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,6 @@ import kotlinx.serialization.Serializable
|
||||||
data class ElementWellKnown(
|
data class ElementWellKnown(
|
||||||
@SerialName("registration_helper_url")
|
@SerialName("registration_helper_url")
|
||||||
val registrationHelperUrl: String? = null,
|
val registrationHelperUrl: String? = null,
|
||||||
|
@SerialName("enforce_element_pro")
|
||||||
|
val enforceElementPro: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.appconfig.OnBoardingConfig
|
import io.element.android.appconfig.OnBoardingConfig
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
|
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.login.LoginHelper
|
import io.element.android.features.login.impl.login.LoginHelper
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -34,6 +35,7 @@ class OnBoardingPresenter @AssistedInject constructor(
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val enterpriseService: EnterpriseService,
|
||||||
|
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val loginHelper: LoginHelper,
|
private val loginHelper: LoginHelper,
|
||||||
) : Presenter<OnBoardingState> {
|
) : Presenter<OnBoardingState> {
|
||||||
|
|
@ -63,7 +65,12 @@ class OnBoardingPresenter @AssistedInject constructor(
|
||||||
val linkAccountProvider by produceState<String?>(initialValue = null) {
|
val linkAccountProvider by produceState<String?>(initialValue = null) {
|
||||||
// Account provider from the link, if allowed by the enterprise service
|
// Account provider from the link, if allowed by the enterprise service
|
||||||
value = params.accountProvider?.takeIf {
|
value = params.accountProvider?.takeIf {
|
||||||
enterpriseService.isAllowedToConnectToHomeserver(it)
|
try {
|
||||||
|
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val defaultAccountProvider = remember(linkAccountProvider) {
|
val defaultAccountProvider = remember(linkAccountProvider) {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
|
||||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
|
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -38,7 +37,7 @@ class QrCodeScanPresenter @Inject constructor(
|
||||||
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
|
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
|
||||||
private val qrCodeLoginManager: QrCodeLoginManager,
|
private val qrCodeLoginManager: QrCodeLoginManager,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||||
) : Presenter<QrCodeScanState> {
|
) : Presenter<QrCodeScanState> {
|
||||||
private var isScanning by mutableStateOf(true)
|
private var isScanning by mutableStateOf(true)
|
||||||
|
|
||||||
|
|
@ -97,10 +96,10 @@ class QrCodeScanPresenter @Inject constructor(
|
||||||
Timber.e(it, "Error parsing QR code data")
|
Timber.e(it, "Error parsing QR code data")
|
||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
val serverName = data.serverName()
|
val serverName = data.serverName()
|
||||||
if (serverName != null && enterpriseService.isAllowedToConnectToHomeserver(serverName).not()) {
|
if (serverName != null) {
|
||||||
throw UnauthorizedAccountProviderException(
|
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
|
||||||
unauthorisedAccountProviderTitle = serverName,
|
title = serverName,
|
||||||
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
|
accountProviderUrl = serverName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
data
|
data
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
package io.element.android.features.login.impl.screens.qrcode.scan
|
package io.element.android.features.login.impl.screens.qrcode.scan
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
|
||||||
|
|
@ -23,12 +23,21 @@ open class QrCodeScanStateProvider : PreviewParameterProvider<QrCodeScanState> {
|
||||||
aQrCodeScanState(
|
aQrCodeScanState(
|
||||||
isScanning = false,
|
isScanning = false,
|
||||||
authenticationAction = AsyncAction.Failure(
|
authenticationAction = AsyncAction.Failure(
|
||||||
UnauthorizedAccountProviderException(
|
AccountProviderAccessException.UnauthorizedAccountProviderException(
|
||||||
unauthorisedAccountProviderTitle = "example.com",
|
unauthorisedAccountProviderTitle = "example.com",
|
||||||
authorisedAccountProviderTitles = listOf("element.io", "element.org"),
|
authorisedAccountProviderTitles = listOf("element.io", "element.org"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
aQrCodeScanState(
|
||||||
|
isScanning = false,
|
||||||
|
authenticationAction = AsyncAction.Failure(
|
||||||
|
AccountProviderAccessException.NeedElementProException(
|
||||||
|
unauthorisedAccountProviderTitle = "example.com",
|
||||||
|
applicationId = "applicationId"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
|
|
@ -145,7 +145,10 @@ private fun ColumnScope.Buttons(
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = when (error) {
|
text = when (error) {
|
||||||
is UnauthorizedAccountProviderException -> {
|
is AccountProviderAccessException.NeedElementProException -> {
|
||||||
|
stringResource(R.string.screen_change_server_error_element_pro_required_title)
|
||||||
|
}
|
||||||
|
is AccountProviderAccessException.UnauthorizedAccountProviderException -> {
|
||||||
stringResource(
|
stringResource(
|
||||||
id = R.string.screen_change_server_error_unauthorized_homeserver_title,
|
id = R.string.screen_change_server_error_unauthorized_homeserver_title,
|
||||||
error.unauthorisedAccountProviderTitle,
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
|
@ -163,7 +166,13 @@ private fun ColumnScope.Buttons(
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = when (error) {
|
text = when (error) {
|
||||||
is UnauthorizedAccountProviderException -> {
|
is AccountProviderAccessException.NeedElementProException -> {
|
||||||
|
stringResource(
|
||||||
|
R.string.screen_change_server_error_element_pro_required_message,
|
||||||
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AccountProviderAccessException.UnauthorizedAccountProviderException -> {
|
||||||
stringResource(
|
stringResource(
|
||||||
id = R.string.screen_change_server_error_unauthorized_homeserver_content,
|
id = R.string.screen_change_server_error_unauthorized_homeserver_content,
|
||||||
error.authorisedAccountProviderTitles.joinToString(),
|
error.authorisedAccountProviderTitles.joinToString(),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<string name="screen_change_account_provider_other">"Other"</string>
|
<string name="screen_change_account_provider_other">"Other"</string>
|
||||||
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
|
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
|
||||||
<string name="screen_change_account_provider_title">"Change account provider"</string>
|
<string name="screen_change_account_provider_title">"Change account provider"</string>
|
||||||
|
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
|
||||||
<string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string>
|
<string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string>
|
||||||
<string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string>
|
<string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string>
|
||||||
<string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string>
|
<string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.accesscontrol
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
|
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
|
||||||
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
|
||||||
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
||||||
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultAccountProviderAccessControlTest {
|
||||||
|
@Test
|
||||||
|
fun `foss build should not allow using account provider that enforce enterprise build`() {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
isAllowedToConnectToHomeserver = true,
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectNeedElementProException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build should not allow using account provider that enforce enterprise build taking precedence over authorization`() {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
// false here.
|
||||||
|
isAllowedToConnectToHomeserver = false,
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectNeedElementProException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build should allow using account provider that does not enforce enterprise build`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
isAllowedToConnectToHomeserver = true,
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build should allow using account provider twith missing key in wellknown`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
isAllowedToConnectToHomeserver = true,
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build should allow using account provider twith missing wellknown`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
isAllowedToConnectToHomeserver = true,
|
||||||
|
elementWellKnown = null,
|
||||||
|
)
|
||||||
|
accessControl.expectAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build should not allow using account provider that do not enforce enterprise build but is not allowed`() {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
isAllowedToConnectToHomeserver = false,
|
||||||
|
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectUnauthorizedAccountProviderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enterprise build should allow using account provider that enforce enterprise build`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = true,
|
||||||
|
isAllowedToConnectToHomeserver = true,
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enterprise build should allow using account provider that do not enforce enterprise build`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = true,
|
||||||
|
isAllowedToConnectToHomeserver = true,
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enterprise build should not allow using account provider that enforce enterprise build but is not allowed`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = true,
|
||||||
|
isAllowedToConnectToHomeserver = false,
|
||||||
|
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectUnauthorizedAccountProviderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enterprise build should not allow using account provider that do not enforce enterprise build but is not allowed`() = runTest {
|
||||||
|
val accessControl = createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild = true,
|
||||||
|
isAllowedToConnectToHomeserver = false,
|
||||||
|
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
|
||||||
|
elementWellKnown = ElementWellKnown(
|
||||||
|
enforceElementPro = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accessControl.expectUnauthorizedAccountProviderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDefaultAccountProviderAccessControl(
|
||||||
|
isEnterpriseBuild: Boolean = false,
|
||||||
|
isAllowedToConnectToHomeserver: Boolean = false,
|
||||||
|
allowedAccountProviders: List<String> = emptyList(),
|
||||||
|
elementWellKnown: ElementWellKnown? = null,
|
||||||
|
) = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = FakeEnterpriseService(
|
||||||
|
isEnterpriseBuild = isEnterpriseBuild,
|
||||||
|
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
|
||||||
|
defaultHomeserverListResult = { allowedAccountProviders },
|
||||||
|
),
|
||||||
|
elementWellknownRetriever = FakeElementWellknownRetriever(
|
||||||
|
retrieveResult = { elementWellKnown }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
|
||||||
|
val exception = assertThrows(AccountProviderAccessException.NeedElementProException::class.java) {
|
||||||
|
runTest {
|
||||||
|
assertIsAllowedToConnectToAccountProvider(
|
||||||
|
title = AN_ACCOUNT_PROVIDER,
|
||||||
|
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER)
|
||||||
|
assertThat(exception.applicationId).isEqualTo("io.element.enterprise")
|
||||||
|
runTest {
|
||||||
|
assertThat(
|
||||||
|
isAllowedToConnectToAccountProvider(
|
||||||
|
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
|
||||||
|
)
|
||||||
|
).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DefaultAccountProviderAccessControl.expectUnauthorizedAccountProviderException() {
|
||||||
|
val exception = assertThrows(AccountProviderAccessException.UnauthorizedAccountProviderException::class.java) {
|
||||||
|
runTest {
|
||||||
|
assertIsAllowedToConnectToAccountProvider(
|
||||||
|
title = AN_ACCOUNT_PROVIDER,
|
||||||
|
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER)
|
||||||
|
assertThat(exception.authorisedAccountProviderTitles).containsExactly(AN_ACCOUNT_PROVIDER_2)
|
||||||
|
runTest {
|
||||||
|
assertThat(
|
||||||
|
isAllowedToConnectToAccountProvider(
|
||||||
|
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
|
||||||
|
)
|
||||||
|
).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun DefaultAccountProviderAccessControl.expectAllowed() {
|
||||||
|
// If no exception is thrown, the test passes
|
||||||
|
assertIsAllowedToConnectToAccountProvider(
|
||||||
|
title = AN_ACCOUNT_PROVIDER,
|
||||||
|
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
|
||||||
|
)
|
||||||
|
runTest {
|
||||||
|
assertThat(
|
||||||
|
isAllowedToConnectToAccountProvider(
|
||||||
|
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
|
||||||
|
)
|
||||||
|
).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.accesscontrol
|
||||||
|
|
||||||
|
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
|
||||||
|
class FakeElementWellknownRetriever(
|
||||||
|
private val retrieveResult: (String) -> ElementWellKnown? = { null },
|
||||||
|
) : ElementWellknownRetriever {
|
||||||
|
override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? = simulateLongTask {
|
||||||
|
retrieveResult(accountProviderUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,15 @@ package io.element.android.features.login.impl.changeserver
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
|
|
@ -106,13 +111,48 @@ class ChangeServerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - change server element pro required error`() = runTest {
|
||||||
|
val retrieveResult = lambdaRecorder<String, ElementWellKnown> {
|
||||||
|
ElementWellKnown(
|
||||||
|
enforceElementPro = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
createPresenter(
|
||||||
|
elementWellknownRetriever = FakeElementWellknownRetriever(
|
||||||
|
retrieveResult = retrieveResult,
|
||||||
|
),
|
||||||
|
).test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||||
|
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
|
||||||
|
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(
|
||||||
|
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
|
||||||
|
).isEqualTo(anAccountProvider.title)
|
||||||
|
assertThat(
|
||||||
|
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
|
||||||
|
).isEqualTo("io.element.enterprise")
|
||||||
|
retrieveResult.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_HOMESERVER_URL.ensureProtocol()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
|
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
|
||||||
) = ChangeServerPresenter(
|
) = ChangeServerPresenter(
|
||||||
authenticationService = authenticationService,
|
authenticationService = authenticationService,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
enterpriseService = enterpriseService,
|
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
|
elementWellknownRetriever = elementWellknownRetriever,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import io.element.android.appconfig.OnBoardingConfig
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
|
||||||
import io.element.android.features.login.impl.login.LoginHelper
|
import io.element.android.features.login.impl.login.LoginHelper
|
||||||
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
|
|
@ -235,6 +238,7 @@ private fun createPresenter(
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
|
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
|
||||||
rageshakeFeatureAvailability: () -> Boolean = { true },
|
rageshakeFeatureAvailability: () -> Boolean = { true },
|
||||||
loginHelper: LoginHelper = createLoginHelper(),
|
loginHelper: LoginHelper = createLoginHelper(),
|
||||||
) = OnBoardingPresenter(
|
) = OnBoardingPresenter(
|
||||||
|
|
@ -242,6 +246,10 @@ private fun createPresenter(
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
enterpriseService = enterpriseService,
|
enterpriseService = enterpriseService,
|
||||||
|
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
|
elementWellknownRetriever = elementWellknownRetriever,
|
||||||
|
),
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
loginHelper = loginHelper,
|
loginHelper = loginHelper,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
|
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
|
|
@ -91,9 +94,15 @@ class QrCodeScanPresenterTest {
|
||||||
assertThat(awaitItem().isScanning).isFalse()
|
assertThat(awaitItem().isScanning).isFalse()
|
||||||
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
|
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle)
|
assertThat(
|
||||||
|
(state.authenticationAction
|
||||||
|
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle
|
||||||
|
)
|
||||||
.isEqualTo("example.com")
|
.isEqualTo("example.com")
|
||||||
assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).authorisedAccountProviderTitles)
|
assertThat(
|
||||||
|
(state.authenticationAction
|
||||||
|
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles
|
||||||
|
)
|
||||||
.containsExactly("element.io")
|
.containsExactly("element.io")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -153,10 +162,14 @@ class QrCodeScanPresenterTest {
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
|
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
|
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
|
||||||
) = QrCodeScanPresenter(
|
) = QrCodeScanPresenter(
|
||||||
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
|
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
|
||||||
qrCodeLoginManager = qrCodeLoginManager,
|
qrCodeLoginManager = qrCodeLoginManager,
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
enterpriseService = enterpriseService,
|
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
|
elementWellknownRetriever = elementWellknownRetriever,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ fun Context.startSharePlainTextIntent(
|
||||||
fun Context.openUrlInExternalApp(
|
fun Context.openUrlInExternalApp(
|
||||||
url: String,
|
url: String,
|
||||||
errorMessage: String = getString(R.string.error_no_compatible_app_found),
|
errorMessage: String = getString(R.string.error_no_compatible_app_found),
|
||||||
|
throwInCaseOfError: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
if (this !is Activity) {
|
if (this !is Activity) {
|
||||||
|
|
@ -173,10 +174,27 @@ fun Context.openUrlInExternalApp(
|
||||||
try {
|
try {
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||||
|
if (throwInCaseOfError) throw activityNotFoundException
|
||||||
toast(errorMessage)
|
toast(errorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Google Play on the provided application Id.
|
||||||
|
*/
|
||||||
|
fun Context.openGooglePlay(
|
||||||
|
appId: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
openUrlInExternalApp(
|
||||||
|
url = "market://details?id=$appId",
|
||||||
|
throwInCaseOfError = true,
|
||||||
|
)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
openUrlInExternalApp("https://play.google.com/store/apps/details?id=$appId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Not in KTX anymore
|
// Not in KTX anymore
|
||||||
fun Context.toast(resId: Int) {
|
fun Context.toast(resId: Int) {
|
||||||
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ const val A_REDACTION_REASON = "A redaction reason"
|
||||||
const val A_HOMESERVER_URL = "matrix.org"
|
const val A_HOMESERVER_URL = "matrix.org"
|
||||||
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
||||||
|
|
||||||
|
const val AN_ACCOUNT_PROVIDER_URL = "https://account.provider.org"
|
||||||
const val AN_ACCOUNT_PROVIDER = "matrix.org"
|
const val AN_ACCOUNT_PROVIDER = "matrix.org"
|
||||||
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
|
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
|
||||||
const val AN_ACCOUNT_PROVIDER_3 = "other.io"
|
const val AN_ACCOUNT_PROVIDER_3 = "other.io"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f682f46ad20ea337ed76ca04e9a8f09f2b214e3f900a69463676d97675579e32
|
||||||
|
size 26776
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f63e4f092e4bd7e6d0eeec2f4176963b2460d1d434a8a7ecc89a72c34ec3b3a9
|
||||||
|
size 25068
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:baa26e4da095cdd3f17d90bfd17a410d4d04c2c255287afdd2f6212764e2417d
|
||||||
|
size 32142
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:77f1ad1ffa6b0f8d8e23a276464c5b9930ec7a694b1646e80b135c1de6d99893
|
||||||
|
size 11352
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:182d98e4af86fa766fc1fd51fa1b295875cdd7b8523716eac6875cda8d78205b
|
||||||
|
size 26213
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1042d3c9994ff687684f47f3e4c64d1d0e7fdb4c35f4b898024b1fdc187fe503
|
||||||
|
size 15322
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fcaa6f6abb01095e62ece79207536c035e70335403a4c6d73c7e18bdc7d0aa5d
|
||||||
|
size 27024
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e84510ac41d16fd20099a2e8d3bedb0b062796d6d8e709b24ce327d0d17d071e
|
||||||
|
size 30022
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ebb8817e231bfae265698c45d030aa055f43848976993de600ae375cb5831f52
|
||||||
|
size 9946
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:628864c47de8fa14e821cf9e4f141888eb70bd4df629c523bd39c0674c6d4fae
|
||||||
|
size 24426
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:248864ef59f926d634045de482019dd9f9927d96cde2a34efeadae0b005e95fb
|
||||||
|
size 13580
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:066175e7a3408f163dc0b1126b3644ab83d08f2627bd0429e3fa4a14e1444c03
|
||||||
|
size 25335
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:83ba365f6653a558c2e85f981477fb9f044ad57006c8c44f51435130ff773216
|
||||||
|
size 30457
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0bb3cc28228eb14cb0552a5ab3e42f3188e70076a6697c4199acab1ca7ac4142
|
||||||
|
size 29240
|
||||||
Loading…
Add table
Add a link
Reference in a new issue