Merge branch 'develop' into feature/bma/focussedButton
This commit is contained in:
@@ -34,6 +34,7 @@ dependencies {
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.oauth.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.pushproviders.api)
|
||||
@@ -61,6 +62,7 @@ dependencies {
|
||||
testImplementation(projects.features.share.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oauth.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushproviders.test)
|
||||
|
||||
@@ -12,4 +12,7 @@ sealed interface LoggedInEvents {
|
||||
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
|
||||
data object CheckSlidingSyncProxyAvailability : LoggedInEvents
|
||||
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents
|
||||
data object DismissLocalNetworkPermissionPrompt : LoggedInEvents
|
||||
|
||||
data object RequestLocationNetworkPermission : LoggedInEvents
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.appnav.loggedin
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -36,6 +38,10 @@ import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvent
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.push.api.PusherRegistrationFailure
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -58,7 +64,12 @@ class LoggedInPresenter(
|
||||
private val encryptionService: EncryptionService,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<LoggedInState> {
|
||||
private val localNetworkPermissionsPresenter: PermissionsPresenter =
|
||||
permissionsPresenterFactory.create(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
|
||||
@Composable
|
||||
override fun present(): LoggedInState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -117,6 +128,21 @@ class LoggedInPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
val localNetworkPermissionState = localNetworkPermissionsPresenter.present()
|
||||
var localNetworkPromptDismissedThisSession by remember { mutableStateOf(false) }
|
||||
val advisorRequestsPrompt by produceState(initialValue = false, localNetworkPermissionState.permissionGranted) {
|
||||
value = localNetworkPermissionAdvisor.shouldRequestPermissionFor(matrixClient.homeserverUrl)
|
||||
}
|
||||
LaunchedEffect(localNetworkPermissionState.permissionGranted) {
|
||||
if (localNetworkPermissionState.permissionGranted) localNetworkPromptDismissedThisSession = false
|
||||
}
|
||||
val localNetworkPermissionDialog = when {
|
||||
!advisorRequestsPrompt -> LocalNetworkPermissionDialog.None
|
||||
localNetworkPromptDismissedThisSession -> LocalNetworkPermissionDialog.None
|
||||
localNetworkPermissionState.shouldShowRationale -> LocalNetworkPermissionDialog.Rationale
|
||||
else -> LocalNetworkPermissionDialog.Settings
|
||||
}
|
||||
|
||||
fun handleEvent(event: LoggedInEvents) {
|
||||
when (event) {
|
||||
is LoggedInEvents.CloseErrorDialog -> {
|
||||
@@ -134,6 +160,17 @@ class LoggedInPresenter(
|
||||
// Force the logout since Native Sliding Sync is already enforced by the SDK
|
||||
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
|
||||
}
|
||||
LoggedInEvents.DismissLocalNetworkPermissionPrompt -> {
|
||||
localNetworkPromptDismissedThisSession = true
|
||||
}
|
||||
LoggedInEvents.RequestLocationNetworkPermission -> {
|
||||
if (localNetworkPermissionDialog == LocalNetworkPermissionDialog.Settings) {
|
||||
localNetworkPermissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
|
||||
} else {
|
||||
localNetworkPermissionState.eventSink(PermissionsEvent.RequestPermissions)
|
||||
}
|
||||
localNetworkPromptDismissedThisSession = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +180,7 @@ class LoggedInPresenter(
|
||||
ignoreRegistrationError = ignoreRegistrationError,
|
||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||
appName = buildMeta.applicationName,
|
||||
localNetworkPermissionDialog = localNetworkPermissionDialog,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
package io.element.android.appnav.loggedin
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
data class LoggedInState(
|
||||
val showSyncSpinner: Boolean,
|
||||
@@ -16,5 +17,6 @@ data class LoggedInState(
|
||||
val ignoreRegistrationError: Boolean,
|
||||
val forceNativeSlidingSyncMigration: Boolean,
|
||||
val appName: String,
|
||||
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
|
||||
val eventSink: (LoggedInEvents) -> Unit,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ package io.element.android.appnav.loggedin
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
import io.element.android.libraries.push.api.PusherRegistrationFailure
|
||||
|
||||
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
|
||||
@@ -19,6 +20,8 @@ open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
|
||||
aLoggedInState(showSyncSpinner = true),
|
||||
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
|
||||
aLoggedInState(forceNativeSlidingSyncMigration = true),
|
||||
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
|
||||
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Settings),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,11 +30,13 @@ fun aLoggedInState(
|
||||
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
forceNativeSlidingSyncMigration: Boolean = false,
|
||||
appName: String = "Element X",
|
||||
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
||||
) = LoggedInState(
|
||||
showSyncSpinner = showSyncSpinner,
|
||||
pusherRegistrationState = pusherRegistrationState,
|
||||
ignoreRegistrationError = false,
|
||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||
appName = appName,
|
||||
localNetworkPermissionDialog = localNetworkPermissionDialog,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.matrix.api.exception.isNetworkError
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.push.api.PusherRegistrationFailure
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -82,6 +83,12 @@ fun LoggedInView(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LocalNetworkPermissionDialogView(
|
||||
dialog = state.localNetworkPermissionDialog,
|
||||
onSubmit = { state.eventSink(LoggedInEvents.RequestLocationNetworkPermission) },
|
||||
onDismiss = { state.eventSink(LoggedInEvents.DismissLocalNetworkPermissionPrompt) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun Throwable.getReason(): String? {
|
||||
|
||||
@@ -36,6 +36,8 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.push.api.PusherRegistrationFailure
|
||||
import io.element.android.libraries.push.test.FakePushService
|
||||
@@ -124,6 +126,8 @@ class LoggedInPresenterTest {
|
||||
encryptionService = encryptionService,
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||
).test {
|
||||
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
||||
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
@@ -368,6 +372,8 @@ class LoggedInPresenterTest {
|
||||
encryptionService = encryptionService,
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -13,4 +13,6 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
sealed interface ChangeServerEvents {
|
||||
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
|
||||
data object ClearError : ChangeServerEvents
|
||||
data object RequestLocalNetworkPermission : ChangeServerEvents
|
||||
data object DismissLocalNetworkPermission : ChangeServerEvents
|
||||
}
|
||||
|
||||
+13
-8
@@ -12,50 +12,55 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class ChangeServerPresenter(
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
|
||||
) : Presenter<ChangeServerState> {
|
||||
@Composable
|
||||
override fun present(): ChangeServerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
val gateState = localNetworkPermissionGate.present<AccountProvider>(
|
||||
urlOf = { provider -> provider.url },
|
||||
onProceed = { provider -> changeServer(provider, changeServerAction) },
|
||||
)
|
||||
|
||||
fun handleEvent(event: ChangeServerEvents) {
|
||||
when (event) {
|
||||
is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction)
|
||||
ChangeServerEvents.ClearError -> changeServerAction.value = AsyncData.Uninitialized
|
||||
is ChangeServerEvents.ChangeServer -> gateState.submit(event.accountProvider)
|
||||
ChangeServerEvents.DismissLocalNetworkPermission -> gateState.abort()
|
||||
ChangeServerEvents.RequestLocalNetworkPermission -> gateState.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
return ChangeServerState(
|
||||
changeServerAction = changeServerAction.value,
|
||||
localNetworkPermissionDialog = gateState.dialog,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.changeServer(
|
||||
private suspend fun changeServer(
|
||||
data: AccountProvider,
|
||||
changeServerAction: MutableState<AsyncData<Unit>>,
|
||||
) = launch {
|
||||
) {
|
||||
suspend {
|
||||
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
|
||||
title = data.title,
|
||||
|
||||
+2
@@ -9,8 +9,10 @@
|
||||
package io.element.android.features.login.impl.changeserver
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
data class ChangeServerState(
|
||||
val changeServerAction: AsyncData<Unit>,
|
||||
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
|
||||
val eventSink: (ChangeServerEvents) -> Unit
|
||||
)
|
||||
|
||||
+4
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
|
||||
override val values: Sequence<ChangeServerState>
|
||||
@@ -39,12 +40,15 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
|
||||
ChangeServerError.UnsupportedServer
|
||||
)
|
||||
),
|
||||
aChangeServerState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
|
||||
)
|
||||
}
|
||||
|
||||
fun aChangeServerState(
|
||||
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
||||
) = ChangeServerState(
|
||||
changeServerAction = changeServerAction,
|
||||
localNetworkPermissionDialog = localNetworkPermissionDialog,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
+10
@@ -27,6 +27,7 @@ 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.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
@@ -121,6 +122,15 @@ fun ChangeServerView(
|
||||
}
|
||||
AsyncData.Uninitialized -> Unit
|
||||
}
|
||||
LocalNetworkPermissionDialogView(
|
||||
dialog = state.localNetworkPermissionDialog,
|
||||
onSubmit = {
|
||||
eventSink.invoke(ChangeServerEvents.RequestLocalNetworkPermission)
|
||||
},
|
||||
onDismiss = {
|
||||
eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
+5
@@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerState
|
||||
import io.element.android.features.login.impl.login.LoginModePresenter
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
@@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter
|
||||
interface LoginModule {
|
||||
@Binds
|
||||
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
|
||||
|
||||
@Binds
|
||||
fun bindLoginModePresenter(presenter: LoginModePresenter): Presenter<LoginModeState>
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.localnetwork
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvent
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class LocalNetworkPermissionGate(
|
||||
private val advisor: LocalNetworkPermissionAdvisor,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) {
|
||||
private val permissionsPresenter: PermissionsPresenter =
|
||||
permissionsPresenterFactory.create(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
|
||||
@Composable
|
||||
fun <T : Any> present(
|
||||
urlOf: (T) -> String,
|
||||
onProceed: suspend (T) -> Unit,
|
||||
): LocalNetworkPermissionGateState<T> {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val permissionsState = permissionsPresenter.present()
|
||||
var pendingSubmit by remember { mutableStateOf<T?>(null) }
|
||||
|
||||
val latestUrlOf by rememberUpdatedState(urlOf)
|
||||
val latestOnProceed by rememberUpdatedState(onProceed)
|
||||
|
||||
LaunchedEffect(permissionsState.permissionGranted, pendingSubmit) {
|
||||
val pending = pendingSubmit
|
||||
if (pending != null && permissionsState.permissionGranted) {
|
||||
coroutineScope.launch { latestOnProceed(pending) }
|
||||
pendingSubmit = null
|
||||
}
|
||||
}
|
||||
|
||||
val dialog by rememberUpdatedState(
|
||||
when {
|
||||
pendingSubmit == null -> LocalNetworkPermissionDialog.None
|
||||
permissionsState.permissionGranted -> LocalNetworkPermissionDialog.None
|
||||
permissionsState.shouldShowRationale || !permissionsState.permissionAlreadyAsked -> LocalNetworkPermissionDialog.Rationale
|
||||
else -> LocalNetworkPermissionDialog.Settings
|
||||
}
|
||||
)
|
||||
|
||||
fun submit(value: T) {
|
||||
coroutineScope.launch {
|
||||
if (advisor.shouldRequestPermissionFor(latestUrlOf(value))) {
|
||||
pendingSubmit = value
|
||||
} else {
|
||||
latestOnProceed(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission() {
|
||||
when (dialog) {
|
||||
LocalNetworkPermissionDialog.Settings -> {
|
||||
permissionsState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
|
||||
}
|
||||
LocalNetworkPermissionDialog.Rationale -> {
|
||||
permissionsState.eventSink(PermissionsEvent.ForceRequestPermissions)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
pendingSubmit = null
|
||||
}
|
||||
|
||||
return LocalNetworkPermissionGateState(
|
||||
dialog = dialog,
|
||||
submit = ::submit,
|
||||
requestPermission = ::requestPermission,
|
||||
abort = ::abort,
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.localnetwork
|
||||
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
data class LocalNetworkPermissionGateState<T>(
|
||||
val dialog: LocalNetworkPermissionDialog,
|
||||
val submit: (T) -> Unit,
|
||||
val requestPermission: () -> Unit,
|
||||
val abort: () -> Unit,
|
||||
)
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.login
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
|
||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
|
||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
|
||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the login flow, including handling OIDC actions and
|
||||
* submitting login requests.
|
||||
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
|
||||
* and [ChooseAccountProviderPresenter].
|
||||
*/
|
||||
@Inject
|
||||
class LoginHelper(
|
||||
private val oAuthActionFlow: OAuthActionFlow,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
|
||||
) {
|
||||
private val loginModeState: MutableState<AsyncData<LoginMode>> = mutableStateOf(AsyncData.Uninitialized)
|
||||
|
||||
@Composable
|
||||
fun collectLoginMode(): State<AsyncData<LoginMode>> {
|
||||
LaunchedEffect(Unit) {
|
||||
oAuthActionFlow.collect { oAuthAction ->
|
||||
if (oAuthAction != null) {
|
||||
onOAuthAction(oAuthAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
return loginModeState
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
loginModeState.value = AsyncData.Uninitialized
|
||||
}
|
||||
|
||||
suspend fun submit(
|
||||
isAccountCreation: Boolean,
|
||||
homeserverUrl: String,
|
||||
resolvedHomeserverUrl: String?,
|
||||
loginHint: String?,
|
||||
) {
|
||||
suspend {
|
||||
authenticationService.setHomeserver(homeserverUrl).recoverCatching {
|
||||
// No .well-known file?
|
||||
// If the homeserver is not reachable, try using resolvedHomeserverUrl.
|
||||
if (resolvedHomeserverUrl != null && resolvedHomeserverUrl != homeserverUrl) {
|
||||
authenticationService.setHomeserver(resolvedHomeserverUrl).getOrThrow()
|
||||
} else {
|
||||
throw it
|
||||
}
|
||||
}.map { matrixHomeServerDetails ->
|
||||
if (matrixHomeServerDetails.supportsOAuthLogin) {
|
||||
// Retrieve the details right now
|
||||
val oAuthPrompt = if (isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
|
||||
LoginMode.OAuth(
|
||||
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = loginHint).getOrThrow()
|
||||
)
|
||||
} else if (isAccountCreation) {
|
||||
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
|
||||
LoginMode.AccountCreation(url)
|
||||
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
|
||||
LoginMode.PasswordLogin
|
||||
} else {
|
||||
error("Unsupported login flow")
|
||||
}
|
||||
}.getOrThrow()
|
||||
}.runCatchingUpdatingState(
|
||||
state = loginModeState,
|
||||
errorTransform = {
|
||||
when (it) {
|
||||
is AccountCreationNotSupported -> it
|
||||
else -> ChangeServerError.from(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onOAuthAction(oAuthAction: OAuthAction) {
|
||||
if (oAuthAction is OAuthAction.GoBack && oAuthAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
|
||||
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
|
||||
// This can happen if there is an error, for instance attempt to login again on the same account.
|
||||
return
|
||||
}
|
||||
loginModeState.value = AsyncData.Loading()
|
||||
when (oAuthAction) {
|
||||
is OAuthAction.GoBack -> {
|
||||
authenticationService.cancelOAuthLogin()
|
||||
.onSuccess {
|
||||
loginModeState.value = AsyncData.Uninitialized
|
||||
}
|
||||
.onFailure { failure ->
|
||||
loginModeState.value = AsyncData.Failure(failure)
|
||||
}
|
||||
}
|
||||
is OAuthAction.Success -> {
|
||||
authenticationService.loginWithOAuth(oAuthAction.url)
|
||||
.onFailure { failure ->
|
||||
loginModeState.value = AsyncData.Failure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
oAuthActionFlow.reset()
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.login
|
||||
|
||||
sealed interface LoginModeEvent {
|
||||
data class Submit(
|
||||
val isAccountCreation: Boolean,
|
||||
val homeserverUrl: String,
|
||||
val resolvedHomeserverUrl: String?,
|
||||
val loginHint: String?,
|
||||
) : LoginModeEvent
|
||||
|
||||
data object ClearError : LoginModeEvent
|
||||
|
||||
data object RequestLocalNetworkPermission : LoginModeEvent
|
||||
|
||||
data object DismissLocalNetworkPermission : LoginModeEvent
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.login
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
|
||||
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
|
||||
import io.element.android.features.login.impl.screens.classic.loginwithclassic.LoginWithClassicPresenter
|
||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
|
||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
|
||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
|
||||
/**
|
||||
* Presenter responsible for managing the login flow, including handling OAuth actions and
|
||||
* submitting login requests.
|
||||
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter],
|
||||
* [ChooseAccountProviderPresenter] and [LoginWithClassicPresenter].
|
||||
*/
|
||||
@Inject
|
||||
class LoginModePresenter(
|
||||
private val oAuthActionFlow: OAuthActionFlow,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
|
||||
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
|
||||
) : Presenter<LoginModeState> {
|
||||
@Composable
|
||||
override fun present(): LoginModeState {
|
||||
val loginMode: MutableState<AsyncData<LoginMode>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
|
||||
val gateState = localNetworkPermissionGate.present<LoginModeEvent.Submit>(
|
||||
urlOf = { it.homeserverUrl },
|
||||
onProceed = { request -> performSubmit(request, loginMode) },
|
||||
)
|
||||
|
||||
// Forward OAuth navigation events to the presenter's own loginMode state.
|
||||
LaunchedEffect(Unit) {
|
||||
oAuthActionFlow.collect { action ->
|
||||
if (action != null) handleOAuthAction(action, loginMode)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: LoginModeEvent) {
|
||||
when (event) {
|
||||
LoginModeEvent.ClearError -> loginMode.value = AsyncData.Uninitialized
|
||||
is LoginModeEvent.Submit -> gateState.submit(event)
|
||||
LoginModeEvent.DismissLocalNetworkPermission -> gateState.abort()
|
||||
LoginModeEvent.RequestLocalNetworkPermission -> gateState.requestPermission()
|
||||
}
|
||||
}
|
||||
return LoginModeState(
|
||||
loginMode = loginMode.value,
|
||||
localNetworkPermissionDialog = gateState.dialog,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState<AsyncData<LoginMode>>) {
|
||||
suspend {
|
||||
authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
|
||||
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
|
||||
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
|
||||
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
|
||||
} else {
|
||||
throw it
|
||||
}
|
||||
}.map { matrixHomeServerDetails ->
|
||||
when {
|
||||
matrixHomeServerDetails.supportsOAuthLogin -> {
|
||||
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
|
||||
LoginMode.OAuth(
|
||||
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
|
||||
)
|
||||
}
|
||||
request.isAccountCreation -> LoginMode.AccountCreation(
|
||||
webClientUrlForAuthenticationRetriever.retrieve(request.homeserverUrl)
|
||||
)
|
||||
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
|
||||
else -> error("Unsupported login flow")
|
||||
}
|
||||
}.getOrThrow()
|
||||
}.runCatchingUpdatingState(
|
||||
state = loginMode,
|
||||
errorTransform = {
|
||||
when (it) {
|
||||
is AccountCreationNotSupported -> it
|
||||
else -> ChangeServerError.from(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleOAuthAction(action: OAuthAction, loginMode: MutableState<AsyncData<LoginMode>>) {
|
||||
if (action is OAuthAction.GoBack && action.toUnblock && loginMode.value !is AsyncData.Loading) {
|
||||
// Ignore GoBack that isn't tied to an in-flight login — it comes from LoginFlowNode
|
||||
// when the user backs out after a completed login attempt.
|
||||
return
|
||||
}
|
||||
loginMode.value = AsyncData.Loading()
|
||||
when (action) {
|
||||
is OAuthAction.GoBack -> authenticationService.cancelOAuthLogin()
|
||||
.onSuccess { loginMode.value = AsyncData.Uninitialized }
|
||||
.onFailure { loginMode.value = AsyncData.Failure(it) }
|
||||
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
|
||||
.onFailure { loginMode.value = AsyncData.Failure(it) }
|
||||
}
|
||||
oAuthActionFlow.reset()
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
@@ -8,7 +7,15 @@
|
||||
|
||||
package io.element.android.features.login.impl.login
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
data class LoginModeState(
|
||||
val loginMode: AsyncData<LoginMode>,
|
||||
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
|
||||
val eventSink: (LoginModeEvent) -> Unit,
|
||||
)
|
||||
|
||||
sealed interface LoginMode {
|
||||
data object PasswordLogin : LoginMode
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.login
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
fun aLoginModeState(
|
||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
||||
eventSink: (LoginModeEvent) -> Unit = {},
|
||||
) = LoginModeState(
|
||||
loginMode = loginMode,
|
||||
localNetworkPermissionDialog = localNetworkPermissionDialog,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+11
-5
@@ -14,6 +14,7 @@ import io.element.android.libraries.core.coroutine.parallelMap
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.core.uri.isValidUrl
|
||||
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -28,6 +29,7 @@ import java.util.Collections
|
||||
class HomeserverResolver(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
|
||||
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
|
||||
) {
|
||||
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
|
||||
val flowContext = currentCoroutineContext()
|
||||
@@ -39,13 +41,17 @@ class HomeserverResolver(
|
||||
// Run all the requests in parallel
|
||||
withContext(dispatchers.io) {
|
||||
list.parallelMap { url ->
|
||||
val isValid = homeServerLoginCompatibilityChecker.check(url)
|
||||
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
|
||||
.getOrNull()
|
||||
?: return@parallelMap
|
||||
// Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
|
||||
// otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
|
||||
// candidate directly; the actual sign-in flow triggers the permission prompt.
|
||||
val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
|
||||
homeServerLoginCompatibilityChecker.check(url)
|
||||
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
|
||||
.getOrNull()
|
||||
?: return@parallelMap
|
||||
|
||||
// Emit the list as soon as possible
|
||||
if (isValid) {
|
||||
if (shouldRequestPermissionOrIsValid) {
|
||||
currentList.add(HomeserverData(homeserverUrl = url))
|
||||
withContext(flowContext) {
|
||||
emit(currentList.toList())
|
||||
|
||||
+17
-18
@@ -12,50 +12,49 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class ChooseAccountProviderPresenter(
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val loginHelper: LoginHelper,
|
||||
private val loginModePresenter: Presenter<LoginModeState>,
|
||||
) : Presenter<ChooseAccountProviderState> {
|
||||
@Composable
|
||||
override fun present(): ChooseAccountProviderState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val loginMode by loginHelper.collectLoginMode()
|
||||
|
||||
val loginModeState = loginModePresenter.present()
|
||||
var selectedAccountProvider: AccountProvider? by remember { mutableStateOf(null) }
|
||||
|
||||
fun handleEvent(event: ChooseAccountProviderEvents) {
|
||||
when (event) {
|
||||
ChooseAccountProviderEvents.Continue -> localCoroutineScope.launch {
|
||||
selectedAccountProvider?.let {
|
||||
loginHelper.submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = it.url,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = null,
|
||||
ChooseAccountProviderEvents.Continue -> {
|
||||
selectedAccountProvider?.let { provider ->
|
||||
loginModeState.eventSink(
|
||||
LoginModeEvent.Submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = provider.url,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is ChooseAccountProviderEvents.SelectAccountProvider -> {
|
||||
// Ensure that the user do not change the server during processing
|
||||
if (loginMode is AsyncData.Uninitialized) {
|
||||
// Ensure that the user does not change the server during processing.
|
||||
if (loginModeState.loginMode is AsyncData.Uninitialized) {
|
||||
selectedAccountProvider = event.accountProvider
|
||||
}
|
||||
}
|
||||
ChooseAccountProviderEvents.ClearError -> loginHelper.clearError()
|
||||
ChooseAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +76,7 @@ class ChooseAccountProviderPresenter(
|
||||
return ChooseAccountProviderState(
|
||||
accountProviders = staticAccountProviderList,
|
||||
selectedAccountProvider = selectedAccountProvider,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-3
@@ -9,16 +9,17 @@
|
||||
package io.element.android.features.login.impl.screens.chooseaccountprovider
|
||||
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ChooseAccountProviderState(
|
||||
val accountProviders: ImmutableList<AccountProvider>,
|
||||
val selectedAccountProvider: AccountProvider?,
|
||||
val loginMode: AsyncData<LoginMode>,
|
||||
val loginModeState: LoginModeState,
|
||||
val eventSink: (ChooseAccountProviderEvents) -> Unit,
|
||||
) {
|
||||
val submitEnabled: Boolean
|
||||
get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
|
||||
get() = selectedAccountProvider != null &&
|
||||
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
|
||||
}
|
||||
|
||||
+5
-4
@@ -11,7 +11,8 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.accountprovider.anAccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@@ -58,7 +59,7 @@ open class ChooseAccountProviderStateProvider : PreviewParameterProvider<ChooseA
|
||||
server3,
|
||||
),
|
||||
selectedAccountProvider = server2,
|
||||
loginMode = AsyncData.Loading(),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Loading()),
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
@@ -69,11 +70,11 @@ fun aChooseAccountProviderState(
|
||||
anAccountProvider()
|
||||
),
|
||||
selectedAccountProvider: AccountProvider? = null,
|
||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
loginModeState: LoginModeState = aLoginModeState(),
|
||||
eventSink: (ChooseAccountProviderEvents) -> Unit = {},
|
||||
) = ChooseAccountProviderState(
|
||||
accountProviders = accountProviders.toImmutableList(),
|
||||
selectedAccountProvider = selectedAccountProvider,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+14
-3
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderView
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeView
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
@@ -44,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
@@ -56,9 +58,9 @@ fun ChooseAccountProviderView(
|
||||
onCreateAccountContinue: (url: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isLoading by remember(state.loginMode) {
|
||||
val isLoading by remember(state.loginModeState.loginMode) {
|
||||
derivedStateOf {
|
||||
state.loginMode is AsyncData.Loading
|
||||
state.loginModeState.loginMode is AsyncData.Loading
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ fun ChooseAccountProviderView(
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
LoginModeView(
|
||||
loginMode = state.loginMode,
|
||||
loginMode = state.loginModeState.loginMode,
|
||||
onClearError = {
|
||||
state.eventSink(ChooseAccountProviderEvents.ClearError)
|
||||
},
|
||||
@@ -135,6 +137,15 @@ fun ChooseAccountProviderView(
|
||||
)
|
||||
}
|
||||
}
|
||||
LocalNetworkPermissionDialogView(
|
||||
dialog = state.loginModeState.localNetworkPermissionDialog,
|
||||
onSubmit = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
|
||||
},
|
||||
onDismiss = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
+13
-10
@@ -20,7 +20,8 @@ import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
@@ -32,7 +33,7 @@ import kotlinx.coroutines.launch
|
||||
class LoginWithClassicPresenter(
|
||||
@Assisted private val userId: UserId,
|
||||
@Assisted private val navigator: LoginWithClassicNavigator,
|
||||
private val loginHelper: LoginHelper,
|
||||
private val loginModePresenter: Presenter<LoginModeState>,
|
||||
private val elementClassicConnection: ElementClassicConnection,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val buildMeta: BuildMeta,
|
||||
@@ -51,7 +52,7 @@ class LoginWithClassicPresenter(
|
||||
var loginWithClassicAction by remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val loginMode by loginHelper.collectLoginMode()
|
||||
val loginModeState = loginModePresenter.present()
|
||||
val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState()
|
||||
|
||||
fun handleEvent(event: LoginWithClassicEvent) {
|
||||
@@ -69,11 +70,13 @@ class LoginWithClassicPresenter(
|
||||
val elementClassicUserId = currentState.elementClassicSession.userId
|
||||
val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol()
|
||||
accountProviderDataSource.setUrl(accountProvider)
|
||||
loginHelper.submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = accountProvider,
|
||||
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
|
||||
loginHint = "mxid:" + elementClassicUserId.value,
|
||||
loginModeState.eventSink(
|
||||
LoginModeEvent.Submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = accountProvider,
|
||||
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
|
||||
loginHint = "mxid:" + elementClassicUserId.value,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +86,7 @@ class LoginWithClassicPresenter(
|
||||
}
|
||||
LoginWithClassicEvent.ClearError -> {
|
||||
loginWithClassicAction = AsyncAction.Uninitialized
|
||||
loginHelper.clearError()
|
||||
loginModeState.eventSink(LoginModeEvent.ClearError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +97,7 @@ class LoginWithClassicPresenter(
|
||||
userId = userId,
|
||||
displayName = elementClassicReady?.displayName,
|
||||
avatar = elementClassicReady?.avatar,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
loginWithClassicAction = loginWithClassicAction,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
||||
+2
-3
@@ -9,9 +9,8 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@Stable
|
||||
@@ -21,6 +20,6 @@ data class LoginWithClassicState(
|
||||
val displayName: String?,
|
||||
val avatar: Bitmap?,
|
||||
val loginWithClassicAction: AsyncAction<Unit>,
|
||||
val loginMode: AsyncData<LoginMode>,
|
||||
val loginModeState: LoginModeState,
|
||||
val eventSink: (LoginWithClassicEvent) -> Unit,
|
||||
)
|
||||
|
||||
+4
-4
@@ -9,9 +9,9 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@@ -29,7 +29,7 @@ fun aLoginWithClassicState(
|
||||
displayName: String? = null,
|
||||
avatar: Bitmap? = null,
|
||||
loginWithClassicAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
loginModeState: LoginModeState = aLoginModeState(),
|
||||
eventSink: (LoginWithClassicEvent) -> Unit = {},
|
||||
) = LoginWithClassicState(
|
||||
isElementPro = isElementPro,
|
||||
@@ -37,6 +37,6 @@ fun aLoginWithClassicState(
|
||||
displayName = displayName,
|
||||
avatar = avatar,
|
||||
loginWithClassicAction = loginWithClassicAction,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+14
-3
@@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeView
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
@@ -50,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -65,9 +67,9 @@ fun LoginWithClassicView(
|
||||
onCreateAccountContinue: (url: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isLoading by remember(state.loginMode) {
|
||||
val isLoading by remember(state.loginModeState.loginMode) {
|
||||
derivedStateOf {
|
||||
state.loginMode is AsyncData.Loading
|
||||
state.loginModeState.loginMode is AsyncData.Loading
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +197,7 @@ fun LoginWithClassicView(
|
||||
}
|
||||
)
|
||||
LoginModeView(
|
||||
loginMode = state.loginMode,
|
||||
loginMode = state.loginModeState.loginMode,
|
||||
onClearError = {
|
||||
state.eventSink(LoginWithClassicEvent.ClearError)
|
||||
},
|
||||
@@ -204,6 +206,15 @@ fun LoginWithClassicView(
|
||||
onNeedLoginPassword = onNeedLoginPassword,
|
||||
onCreateAccountContinue = onCreateAccountContinue,
|
||||
)
|
||||
LocalNetworkPermissionDialogView(
|
||||
dialog = state.loginModeState.localNetworkPermissionDialog,
|
||||
onSubmit = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
|
||||
},
|
||||
onDismiss = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
+9
-12
@@ -11,20 +11,19 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
class ConfirmAccountProviderPresenter(
|
||||
@Assisted private val params: Params,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val loginHelper: LoginHelper,
|
||||
private val loginModePresenter: Presenter<LoginModeState>,
|
||||
) : Presenter<ConfirmAccountProviderState> {
|
||||
data class Params(
|
||||
val isAccountCreation: Boolean,
|
||||
@@ -38,28 +37,26 @@ class ConfirmAccountProviderPresenter(
|
||||
@Composable
|
||||
override fun present(): ConfirmAccountProviderState {
|
||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val loginMode by loginHelper.collectLoginMode()
|
||||
val loginModeState = loginModePresenter.present()
|
||||
|
||||
fun handleEvent(event: ConfirmAccountProviderEvents) {
|
||||
when (event) {
|
||||
ConfirmAccountProviderEvents.Continue -> localCoroutineScope.launch {
|
||||
loginHelper.submit(
|
||||
ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink(
|
||||
LoginModeEvent.Submit(
|
||||
isAccountCreation = params.isAccountCreation,
|
||||
homeserverUrl = accountProvider.url,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = null,
|
||||
)
|
||||
}
|
||||
ConfirmAccountProviderEvents.ClearError -> loginHelper.clearError()
|
||||
)
|
||||
ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
|
||||
}
|
||||
}
|
||||
|
||||
return ConfirmAccountProviderState(
|
||||
accountProvider = accountProvider,
|
||||
isAccountCreation = params.isAccountCreation,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-3
@@ -9,14 +9,16 @@
|
||||
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
data class ConfirmAccountProviderState(
|
||||
val accountProvider: AccountProvider,
|
||||
val isAccountCreation: Boolean,
|
||||
val loginMode: AsyncData<LoginMode>,
|
||||
val loginModeState: LoginModeState,
|
||||
val eventSink: (ConfirmAccountProviderEvents) -> Unit
|
||||
) {
|
||||
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
|
||||
val submitEnabled: Boolean
|
||||
get() = accountProvider.url.isNotEmpty() &&
|
||||
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
|
||||
}
|
||||
|
||||
+5
-4
@@ -11,7 +11,8 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.accountprovider.anAccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
@@ -24,7 +25,7 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
|
||||
),
|
||||
aConfirmAccountProviderState(
|
||||
isAccountCreation = true,
|
||||
loginMode = AsyncData.Failure(AccountCreationNotSupported())
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AccountCreationNotSupported())),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -32,11 +33,11 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
|
||||
private fun aConfirmAccountProviderState(
|
||||
accountProvider: AccountProvider = anAccountProvider(),
|
||||
isAccountCreation: Boolean = false,
|
||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
loginModeState: LoginModeState = aLoginModeState(),
|
||||
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
|
||||
) = ConfirmAccountProviderState(
|
||||
accountProvider = accountProvider,
|
||||
isAccountCreation = isAccountCreation,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+15
-3
@@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeView
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
@@ -31,6 +32,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -45,9 +47,9 @@ fun ConfirmAccountProviderView(
|
||||
onChange: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isLoading by remember(state.loginMode) {
|
||||
val isLoading by remember(state.loginModeState.loginMode) {
|
||||
derivedStateOf {
|
||||
state.loginMode is AsyncData.Loading
|
||||
state.loginModeState.loginMode is AsyncData.Loading
|
||||
}
|
||||
}
|
||||
val eventSink = state.eventSink
|
||||
@@ -98,7 +100,7 @@ fun ConfirmAccountProviderView(
|
||||
}
|
||||
) {
|
||||
LoginModeView(
|
||||
loginMode = state.loginMode,
|
||||
loginMode = state.loginModeState.loginMode,
|
||||
onClearError = {
|
||||
eventSink(ConfirmAccountProviderEvents.ClearError)
|
||||
},
|
||||
@@ -108,6 +110,16 @@ fun ConfirmAccountProviderView(
|
||||
onCreateAccountContinue = onCreateAccountContinue,
|
||||
)
|
||||
}
|
||||
|
||||
LocalNetworkPermissionDialogView(
|
||||
dialog = state.loginModeState.localNetworkPermissionDialog,
|
||||
onSubmit = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
|
||||
},
|
||||
onDismiss = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
+13
-10
@@ -25,7 +25,8 @@ import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
|
||||
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
@@ -41,7 +42,7 @@ class OnBoardingPresenter(
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||
private val loginHelper: LoginHelper,
|
||||
private val loginModePresenter: Presenter<LoginModeState>,
|
||||
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
||||
private val sessionStore: SessionStore,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
@@ -98,21 +99,23 @@ class OnBoardingPresenter(
|
||||
value = sessionStore.numberOfSessions() > 0
|
||||
}
|
||||
|
||||
val loginMode by loginHelper.collectLoginMode()
|
||||
val loginModeState = loginModePresenter.present()
|
||||
|
||||
fun handleEvent(event: OnBoardingEvents) {
|
||||
when (event) {
|
||||
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
|
||||
// Ensure that the current account provider is set
|
||||
accountProviderDataSource.setUrl(event.defaultAccountProvider)
|
||||
loginHelper.submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = event.defaultAccountProvider,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
||||
loginModeState.eventSink(
|
||||
LoginModeEvent.Submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = event.defaultAccountProvider,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
||||
)
|
||||
)
|
||||
}
|
||||
OnBoardingEvents.ClearError -> loginHelper.clearError()
|
||||
OnBoardingEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
|
||||
OnBoardingEvents.OnVersionClick -> {
|
||||
if (canReportBug) {
|
||||
if (multipleTapToUnlock.unlock(localCoroutineScope)) {
|
||||
@@ -133,7 +136,7 @@ class OnBoardingPresenter(
|
||||
canLoginWithQrCode = canLoginWithQrCode,
|
||||
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
|
||||
canReportBug = canReportBug && showReportBug,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
version = buildMeta.versionName,
|
||||
onBoardingLogoResId = onBoardingLogoResId,
|
||||
eventSink = ::handleEvent,
|
||||
|
||||
+4
-3
@@ -9,7 +9,7 @@
|
||||
package io.element.android.features.login.impl.screens.onboarding
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
data class OnBoardingState(
|
||||
@@ -25,9 +25,10 @@ data class OnBoardingState(
|
||||
val version: String,
|
||||
@DrawableRes
|
||||
val onBoardingLogoResId: Int?,
|
||||
val loginMode: AsyncData<LoginMode>,
|
||||
val loginModeState: LoginModeState,
|
||||
val eventSink: (OnBoardingEvents) -> Unit,
|
||||
) {
|
||||
val submitEnabled: Boolean
|
||||
get() = defaultAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
|
||||
get() = defaultAccountProvider != null &&
|
||||
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,8 +10,8 @@ package io.element.android.features.login.impl.screens.onboarding
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.features.login.impl.login.LoginModeState
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.libraries.designsystem.R
|
||||
|
||||
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||
@@ -49,7 +49,7 @@ fun anOnBoardingState(
|
||||
version: String = "1.0.0",
|
||||
@DrawableRes
|
||||
customLogoResId: Int? = null,
|
||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
loginModeState: LoginModeState = aLoginModeState(),
|
||||
eventSink: (OnBoardingEvents) -> Unit = {},
|
||||
) = OnBoardingState(
|
||||
isAddingAccount = isAddingAccount,
|
||||
@@ -62,7 +62,7 @@ fun anOnBoardingState(
|
||||
canCreateAccount = canCreateAccount,
|
||||
canReportBug = canReportBug,
|
||||
version = version,
|
||||
loginMode = loginMode,
|
||||
loginModeState = loginModeState,
|
||||
onBoardingLogoResId = customLogoResId,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+14
-3
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||
import io.element.android.features.login.impl.login.LoginModeView
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||
@@ -50,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -74,7 +76,7 @@ fun OnBoardingView(
|
||||
) {
|
||||
val loginView = @Composable {
|
||||
LoginModeView(
|
||||
loginMode = state.loginMode,
|
||||
loginMode = state.loginModeState.loginMode,
|
||||
onClearError = {
|
||||
state.eventSink(OnBoardingEvents.ClearError)
|
||||
},
|
||||
@@ -83,6 +85,15 @@ fun OnBoardingView(
|
||||
onNeedLoginPassword = onNeedLoginPassword,
|
||||
onCreateAccountContinue = onCreateAccountContinue,
|
||||
)
|
||||
LocalNetworkPermissionDialogView(
|
||||
dialog = state.loginModeState.localNetworkPermissionDialog,
|
||||
onSubmit = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
|
||||
},
|
||||
onDismiss = {
|
||||
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
|
||||
}
|
||||
)
|
||||
}
|
||||
val buttons = @Composable {
|
||||
OnBoardingButtons(
|
||||
@@ -260,9 +271,9 @@ private fun OnBoardingButtons(
|
||||
onCreateAccount: () -> Unit,
|
||||
onReportProblem: () -> Unit,
|
||||
) {
|
||||
val isLoading by remember(state.loginMode) {
|
||||
val isLoading by remember(state.loginModeState.loginMode) {
|
||||
derivedStateOf {
|
||||
state.loginMode is AsyncData.Loading
|
||||
state.loginModeState.loginMode is AsyncData.Loading
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -15,6 +15,7 @@ import io.element.android.features.login.impl.accesscontrol.DefaultAccountProvid
|
||||
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.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -23,6 +24,10 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
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.aMatrixHomeServerDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
@@ -43,6 +48,7 @@ class ChangeServerPresenterTest {
|
||||
createPresenter().test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +189,81 @@ class ChangeServerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - advisor advises prompt, dialog shown before setHomeserver runs`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService(
|
||||
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
|
||||
)
|
||||
createPresenter(
|
||||
authenticationService = authenticationService,
|
||||
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
|
||||
permissionsPresenter = FakePermissionsPresenter(),
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
|
||||
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
|
||||
val promptState = expectMostRecentItem()
|
||||
// Dialog is shown, permission has not been requested yet, setHomeserver has not been called.
|
||||
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
|
||||
assertThat(promptState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - dismissing the dialog aborts the submit`() = runTest {
|
||||
createPresenter(
|
||||
authenticationService = FakeMatrixAuthenticationService(
|
||||
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
|
||||
),
|
||||
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
|
||||
permissionsPresenter = FakePermissionsPresenter(),
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
|
||||
val promptState = expectMostRecentItem()
|
||||
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
|
||||
promptState.eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission)
|
||||
val dismissedState = expectMostRecentItem()
|
||||
assertThat(dismissedState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
|
||||
assertThat(dismissedState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - advisor advises prompt, permission granted resumes setHomeserver`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService(
|
||||
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
|
||||
)
|
||||
val permissionsPresenter = FakePermissionsPresenter()
|
||||
createPresenter(
|
||||
authenticationService = authenticationService,
|
||||
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
|
||||
permissionsPresenter = permissionsPresenter,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
|
||||
val promptState = expectMostRecentItem()
|
||||
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
// Await recompositions until the deferred setHomeserver completes.
|
||||
var finalState = awaitItem()
|
||||
while (finalState.changeServerAction !is AsyncData.Success) {
|
||||
finalState = awaitItem()
|
||||
}
|
||||
assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
||||
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
|
||||
) = ChangeServerPresenter(
|
||||
authenticationService = authenticationService,
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
@@ -195,5 +271,9 @@ class ChangeServerPresenterTest {
|
||||
enterpriseService = enterpriseService,
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
),
|
||||
localNetworkPermissionGate = LocalNetworkPermissionGate(
|
||||
advisor = localNetworkPermissionAdvisor,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+10
-10
@@ -12,8 +12,8 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
|
||||
import io.element.android.features.login.impl.login.LoginModePresenter
|
||||
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
||||
@@ -81,7 +81,7 @@ class ChooseAccountProviderPresenterTest {
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
||||
),
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
)
|
||||
@@ -105,7 +105,7 @@ class ChooseAccountProviderPresenterTest {
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
||||
),
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
)
|
||||
@@ -121,12 +121,12 @@ class ChooseAccountProviderPresenterTest {
|
||||
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(ChooseAccountProviderEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ class ChooseAccountProviderPresenterTest {
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
||||
),
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
)
|
||||
@@ -152,7 +152,7 @@ class ChooseAccountProviderPresenterTest {
|
||||
it.eventSink(ChooseAccountProviderEvents.Continue)
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.loginMode.isLoading()).isTrue()
|
||||
assertThat(it.loginModeState.loginMode.isLoading()).isTrue()
|
||||
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider2))
|
||||
}
|
||||
expectNoEvents()
|
||||
@@ -162,8 +162,8 @@ class ChooseAccountProviderPresenterTest {
|
||||
|
||||
private fun createPresenter(
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
loginHelper: LoginHelper = createLoginHelper(),
|
||||
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
|
||||
) = ChooseAccountProviderPresenter(
|
||||
enterpriseService = enterpriseService,
|
||||
loginHelper = loginHelper,
|
||||
loginModePresenter = loginModePresenter,
|
||||
)
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.impl.accountprovider.anAccountProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import org.junit.Test
|
||||
|
||||
@@ -35,7 +36,7 @@ class ChooseAccountProviderStateTest {
|
||||
fun `submitEnabled returns false when there is a selectedAccountProvider but there is an error`() {
|
||||
val sut = aChooseAccountProviderState(
|
||||
selectedAccountProvider = anAccountProvider(),
|
||||
loginMode = AsyncData.Failure(Throwable("Error")),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(Throwable("Error"))),
|
||||
)
|
||||
assertThat(sut.submitEnabled).isFalse()
|
||||
}
|
||||
@@ -44,7 +45,7 @@ class ChooseAccountProviderStateTest {
|
||||
fun `submitEnabled returns false when there is a selectedAccountProvider but the result is successful`() {
|
||||
val sut = aChooseAccountProviderState(
|
||||
selectedAccountProvider = anAccountProvider(),
|
||||
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)),
|
||||
)
|
||||
assertThat(sut.submitEnabled).isFalse()
|
||||
}
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.login.impl.accountprovider.anAccountProvider
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
@@ -69,7 +70,7 @@ class ChooseAccountProviderViewTest : RobolectricTest() {
|
||||
val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
|
||||
setChooseAccountProviderView(
|
||||
state = aChooseAccountProviderState(
|
||||
loginMode = AsyncData.Failure(AN_EXCEPTION),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)),
|
||||
eventSink = eventSink,
|
||||
),
|
||||
)
|
||||
|
||||
+9
-9
@@ -16,8 +16,8 @@ import io.element.android.features.login.impl.classic.FakeElementClassicConnecti
|
||||
import io.element.android.features.login.impl.classic.ROOM_KEYS_VERSION
|
||||
import io.element.android.features.login.impl.classic.anElementClassicReady
|
||||
import io.element.android.features.login.impl.classic.anElementClassicSession
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
|
||||
import io.element.android.features.login.impl.login.LoginModePresenter
|
||||
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||
@@ -42,7 +42,7 @@ class LoginWithClassicPresenterTest {
|
||||
assertThat(initialState.displayName).isNull()
|
||||
assertThat(initialState.avatar).isNull()
|
||||
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
|
||||
assertThat(initialState.loginMode.isUninitialized()).isTrue()
|
||||
assertThat(initialState.loginModeState.loginMode.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class LoginWithClassicPresenterTest {
|
||||
)
|
||||
val presenter = createPresenter(
|
||||
elementClassicConnection = elementClassicConnection,
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
)
|
||||
@@ -108,7 +108,7 @@ class LoginWithClassicPresenterTest {
|
||||
)
|
||||
val presenter = createPresenter(
|
||||
elementClassicConnection = elementClassicConnection,
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
)
|
||||
@@ -147,7 +147,7 @@ class LoginWithClassicPresenterTest {
|
||||
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
|
||||
val presenter = createPresenter(
|
||||
elementClassicConnection = elementClassicConnection,
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
navigator = FakeLoginWithClassicNavigator(
|
||||
@@ -188,7 +188,7 @@ class LoginWithClassicPresenterTest {
|
||||
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
|
||||
val presenter = createPresenter(
|
||||
elementClassicConnection = elementClassicConnection,
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
navigator = FakeLoginWithClassicNavigator(
|
||||
@@ -247,14 +247,14 @@ class LoginWithClassicPresenterTest {
|
||||
private fun createPresenter(
|
||||
userId: UserId = A_USER_ID,
|
||||
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
|
||||
loginHelper: LoginHelper = createLoginHelper(),
|
||||
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
|
||||
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
|
||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||
isEnterpriseBuild: Boolean = false,
|
||||
) = LoginWithClassicPresenter(
|
||||
userId = userId,
|
||||
navigator = navigator,
|
||||
loginHelper = loginHelper,
|
||||
loginModePresenter = loginModePresenter,
|
||||
elementClassicConnection = elementClassicConnection,
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
buildMeta = aBuildMeta(
|
||||
|
||||
+38
-38
@@ -14,7 +14,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
|
||||
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
|
||||
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -43,7 +43,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||
assertThat(initialState.isAccountCreation).isFalse()
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
|
||||
assertThat(initialState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +62,11 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
|
||||
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,11 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,15 +110,15 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
authenticationService.givenOAuthCancelError(AN_EXCEPTION)
|
||||
defaultOAuthActionFlow.post(OAuthAction.GoBack())
|
||||
val cancelFailureState = awaitItem()
|
||||
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,14 +139,14 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
defaultOAuthActionFlow.post(OAuthAction.GoBack())
|
||||
val cancelFinalState = awaitItem()
|
||||
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,10 +167,10 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
|
||||
val cancelFinalState = awaitItem()
|
||||
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,17 +191,17 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
authenticationService.givenLoginError(AN_EXCEPTION)
|
||||
defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
|
||||
val cancelLoadingState = awaitItem()
|
||||
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val cancelFailureState = awaitItem()
|
||||
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,14 +222,14 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
|
||||
val successSuccessState = awaitItem()
|
||||
assertThat(successSuccessState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||
skipItems(1) // Loading
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.submitEnabled).isFalse()
|
||||
assertThat(failureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,12 +273,12 @@ class ConfirmAccountProviderPresenterTest {
|
||||
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,11 +302,11 @@ class ConfirmAccountProviderPresenterTest {
|
||||
skipItems(1) // Loading
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,8 +326,8 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||
skipItems(1) // Loading
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,8 +349,8 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||
skipItems(1) // Loading
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||
skipItems(1) // Loading
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
|
||||
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||
) = ConfirmAccountProviderPresenter(
|
||||
params = params,
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = matrixAuthenticationService,
|
||||
oAuthActionFlow = defaultOAuthActionFlow,
|
||||
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
|
||||
|
||||
+18
-8
@@ -15,7 +15,8 @@ import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
|
||||
import io.element.android.features.login.impl.login.LoginModePresenter
|
||||
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||
@@ -33,6 +34,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
@@ -255,7 +257,7 @@ class OnBoardingPresenterTest {
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
isAllowedToConnectToHomeserverResult = { true },
|
||||
),
|
||||
loginHelper = createLoginHelper(
|
||||
loginModePresenter = createLoginModePresenter(
|
||||
authenticationService = authenticationService,
|
||||
),
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
@@ -271,12 +273,12 @@ class OnBoardingPresenterTest {
|
||||
assertThat(accountProviderDataSource.flow.first().url).isEqualTo(A_HOMESERVER_URL_2)
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(OnBoardingEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,7 +294,7 @@ private fun createPresenter(
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
||||
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
|
||||
loginHelper: LoginHelper = createLoginHelper(),
|
||||
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
|
||||
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||
@@ -305,18 +307,26 @@ private fun createPresenter(
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
),
|
||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
loginHelper = loginHelper,
|
||||
loginModePresenter = loginModePresenter,
|
||||
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||
sessionStore = sessionStore,
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
)
|
||||
|
||||
fun createLoginHelper(
|
||||
fun createLoginModePresenter(
|
||||
oAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
|
||||
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
|
||||
): LoginHelper = LoginHelper(
|
||||
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
|
||||
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
|
||||
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
|
||||
): LoginModePresenter = LoginModePresenter(
|
||||
oAuthActionFlow = oAuthActionFlow,
|
||||
authenticationService = authenticationService,
|
||||
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
|
||||
localNetworkPermissionGate = LocalNetworkPermissionGate(
|
||||
advisor = localNetworkPermissionAdvisor,
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
),
|
||||
)
|
||||
|
||||
+5
-4
@@ -21,6 +21,7 @@ import com.google.testing.junit.testparameterinjector.KotlinTestParameters.named
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.login.aLoginModeState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
@@ -148,7 +149,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
|
||||
setOnboardingView(
|
||||
state = anOnBoardingState(
|
||||
defaultAccountProvider = "element.io",
|
||||
loginMode = AsyncData.Failure(AN_EXCEPTION),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)),
|
||||
eventSink = eventSink,
|
||||
),
|
||||
)
|
||||
@@ -208,7 +209,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
|
||||
ensureCalledOnce { callback ->
|
||||
setOnboardingView(
|
||||
state = anOnBoardingState(
|
||||
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)),
|
||||
eventSink = eventSink,
|
||||
),
|
||||
onNeedLoginPassword = callback,
|
||||
@@ -224,7 +225,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
|
||||
ensureCalledOnceWithParam(oAuthDetails) { callback ->
|
||||
setOnboardingView(
|
||||
state = anOnBoardingState(
|
||||
loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails)),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails))),
|
||||
eventSink = eventSink,
|
||||
),
|
||||
onOAuthDetails = callback,
|
||||
@@ -240,7 +241,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
|
||||
ensureCalledOnceWithParam(oAuthDetails.url) { callback ->
|
||||
setOnboardingView(
|
||||
state = anOnBoardingState(
|
||||
loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")),
|
||||
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl"))),
|
||||
eventSink = eventSink,
|
||||
),
|
||||
onCreateAccountContinue = callback,
|
||||
|
||||
+6
-5
@@ -13,6 +13,7 @@ import io.element.android.features.login.impl.changeserver.aChangeServerState
|
||||
import io.element.android.features.login.impl.resolver.HomeserverResolver
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -30,7 +31,7 @@ class SearchAccountProviderPresenterTest {
|
||||
fun `present - initial state`() = runTest {
|
||||
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) })
|
||||
val presenter = SearchAccountProviderPresenter(
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
|
||||
changeServerPresenter = { aChangeServerState() }
|
||||
)
|
||||
presenter.test {
|
||||
@@ -44,7 +45,7 @@ class SearchAccountProviderPresenterTest {
|
||||
fun `present - error while checking login compatibility`() = runTest {
|
||||
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) })
|
||||
val presenter = SearchAccountProviderPresenter(
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
|
||||
changeServerPresenter = { aChangeServerState() }
|
||||
)
|
||||
presenter.test {
|
||||
@@ -68,7 +69,7 @@ class SearchAccountProviderPresenterTest {
|
||||
fun `present - enter text no result`() = runTest {
|
||||
val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) })
|
||||
val presenter = SearchAccountProviderPresenter(
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()),
|
||||
changeServerPresenter = { aChangeServerState() }
|
||||
)
|
||||
presenter.test {
|
||||
@@ -101,7 +102,7 @@ class SearchAccountProviderPresenterTest {
|
||||
}
|
||||
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
|
||||
val presenter = SearchAccountProviderPresenter(
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
|
||||
changeServerPresenter = { aChangeServerState() }
|
||||
)
|
||||
presenter.test {
|
||||
@@ -141,7 +142,7 @@ class SearchAccountProviderPresenterTest {
|
||||
}
|
||||
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
|
||||
val presenter = SearchAccountProviderPresenter(
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
|
||||
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
|
||||
changeServerPresenter = { aChangeServerState() }
|
||||
)
|
||||
presenter.test {
|
||||
|
||||
@@ -7,4 +7,6 @@
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
</manifest>
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.androidutils.network
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetAddress
|
||||
|
||||
interface DnsResolver {
|
||||
suspend fun resolve(host: String): List<InetAddress>
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultDnsResolver : DnsResolver {
|
||||
override suspend fun resolve(host: String): List<InetAddress> = withContext(Dispatchers.IO) {
|
||||
InetAddress.getAllByName(host).toList()
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.androidutils.network
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
|
||||
sealed interface LocalNetworkClassification {
|
||||
data object PublicIp : LocalNetworkClassification
|
||||
data object LocalIp : LocalNetworkClassification
|
||||
data object Unresolvable : LocalNetworkClassification
|
||||
}
|
||||
|
||||
interface LocalNetworkAddressClassifier {
|
||||
/**
|
||||
* Classify [url] as pointing to a local network, a public host, or unresolvable.
|
||||
*/
|
||||
suspend fun classify(url: String): LocalNetworkClassification
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLocalNetworkAddressClassifier(
|
||||
private val dnsResolver: DnsResolver,
|
||||
) : LocalNetworkAddressClassifier {
|
||||
override suspend fun classify(url: String): LocalNetworkClassification {
|
||||
val host = extractHost(url) ?: return LocalNetworkClassification.Unresolvable
|
||||
// `.local` domains are always link-local by definition
|
||||
if (host.endsWith(".local", ignoreCase = true)) return LocalNetworkClassification.LocalIp
|
||||
|
||||
val resolved = tryOrNull { dnsResolver.resolve(host) }
|
||||
|
||||
if (resolved.isNullOrEmpty()) return LocalNetworkClassification.Unresolvable
|
||||
|
||||
return if (resolved.any { it.isLocalRange() }) {
|
||||
LocalNetworkClassification.LocalIp
|
||||
} else {
|
||||
LocalNetworkClassification.PublicIp
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractHost(url: String): String? {
|
||||
return tryOrNull {
|
||||
val uri = URI(url)
|
||||
uri.host?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun InetAddress.isLocalRange(): Boolean {
|
||||
if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress) return true
|
||||
// Cases not covered by jvm
|
||||
// 1. IPV4 100.64.0.0/10
|
||||
if (this is Inet4Address) {
|
||||
val bytes = address
|
||||
val b0 = bytes[0].toInt() and 0xff
|
||||
val b1 = bytes[1].toInt() and 0xff
|
||||
if (b0 == 100 && b1 in 64..127) return true
|
||||
}
|
||||
// 2. IPV6 fc00::/7
|
||||
if (this is Inet6Address) {
|
||||
val firstByte = address[0].toInt() and 0xff
|
||||
if (firstByte and 0xfe == 0xfc) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.androidutils.network
|
||||
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class FakeDnsResolver(
|
||||
private val results: Map<String, List<InetAddress>> = emptyMap(),
|
||||
private val throwOnUnknown: Boolean = true,
|
||||
) : DnsResolver {
|
||||
override suspend fun resolve(host: String): List<InetAddress> {
|
||||
// Mimic production DefaultDnsResolver: InetAddress resolves IP literals synchronously
|
||||
// (no DNS lookup), so tests can pass literals through without registering them in [results].
|
||||
if (looksLikeIpLiteral(host)) {
|
||||
tryOrNull { InetAddress.getAllByName(host).toList() }?.let { return it }
|
||||
}
|
||||
return results[host]
|
||||
?: if (throwOnUnknown) throw UnknownHostException(host) else emptyList()
|
||||
}
|
||||
|
||||
private fun looksLikeIpLiteral(host: String): Boolean {
|
||||
if (':' in host) return true
|
||||
val parts = host.split('.')
|
||||
return parts.size == 4 && parts.all { it.toIntOrNull() != null }
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.androidutils.network
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.net.InetAddress
|
||||
|
||||
class LocalNetworkAddressClassifierTest {
|
||||
private fun classifier(
|
||||
resolver: DnsResolver = FakeDnsResolver(),
|
||||
) = DefaultLocalNetworkAddressClassifier(resolver)
|
||||
|
||||
@Test
|
||||
fun `IPv4 loopback literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://127.0.0.1:8008")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 RFC1918 10 literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://10.0.0.5")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 RFC1918 172_16 literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://172.20.1.2")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 RFC1918 192_168 literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://192.168.1.10")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 link local literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://169.254.10.20")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 CGNAT 100_64_0_0_10 literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://100.100.0.1")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 public literal is PublicIp`() = runTest {
|
||||
assertThat(classifier().classify("https://8.8.8.8")).isEqualTo(LocalNetworkClassification.PublicIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 loopback literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://[::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 link local literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://[fe80::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 unique local literal is LocalIp`() = runTest {
|
||||
assertThat(classifier().classify("https://[fc00::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 public literal is PublicIp`() = runTest {
|
||||
assertThat(classifier().classify("https://[2606:4700:4700::1111]")).isEqualTo(LocalNetworkClassification.PublicIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dot local mDNS name is LocalIp without DNS`() = runTest {
|
||||
assertThat(classifier().classify("https://matrix.local"))
|
||||
.isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hostname resolving to public IP is PublicIp`() = runTest {
|
||||
val resolver = FakeDnsResolver(
|
||||
results = mapOf("matrix.org" to listOf(InetAddress.getByName("8.8.8.8")))
|
||||
)
|
||||
assertThat(classifier(resolver).classify("https://matrix.org"))
|
||||
.isEqualTo(LocalNetworkClassification.PublicIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hostname resolving to private IP is LocalIp`() = runTest {
|
||||
val resolver = FakeDnsResolver(
|
||||
results = mapOf("matrix.corp.internal" to listOf(InetAddress.getByName("10.0.0.5")))
|
||||
)
|
||||
assertThat(classifier(resolver).classify("https://matrix.corp.internal"))
|
||||
.isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hostname with mixed public and private addresses is LocalIp`() = runTest {
|
||||
val resolver = FakeDnsResolver(
|
||||
results = mapOf(
|
||||
"matrix.example" to listOf(
|
||||
InetAddress.getByName("8.8.8.8"),
|
||||
InetAddress.getByName("10.0.0.5"),
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(classifier(resolver).classify("https://matrix.example"))
|
||||
.isEqualTo(LocalNetworkClassification.LocalIp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unresolvable hostname is Unresolvable`() = runTest {
|
||||
val resolver = FakeDnsResolver(throwOnUnknown = true)
|
||||
assertThat(classifier(resolver).classify("https://nonexistent.example"))
|
||||
.isEqualTo(LocalNetworkClassification.Unresolvable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed URL is Unresolvable`() = runTest {
|
||||
assertThat(classifier().classify("not a url")).isEqualTo(LocalNetworkClassification.Unresolvable)
|
||||
}
|
||||
}
|
||||
+3
@@ -56,6 +56,9 @@ import java.util.Optional
|
||||
interface MatrixClient {
|
||||
val sessionId: SessionId
|
||||
val deviceId: DeviceId
|
||||
|
||||
/** Base URL of the homeserver this client is connected to. */
|
||||
val homeserverUrl: String
|
||||
val sessionPaths: SessionPaths
|
||||
val userProfile: StateFlow<MatrixUser>
|
||||
val roomListService: RoomListService
|
||||
|
||||
+1
@@ -158,6 +158,7 @@ class RustMatrixClient(
|
||||
) : MatrixClient {
|
||||
override val sessionId: UserId = UserId(innerClient.userId())
|
||||
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
||||
override val homeserverUrl: String = innerClient.homeserver()
|
||||
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
|
||||
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
|
||||
|
||||
|
||||
+3
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
|
||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
@@ -41,6 +42,7 @@ import uniffi.matrix_sdk_base.MediaRetentionPolicy
|
||||
class FakeFfiClient(
|
||||
private val userId: String = A_USER_ID.value,
|
||||
private val deviceId: String = A_DEVICE_ID.value,
|
||||
private val homeserver: String = A_HOMESERVER_URL,
|
||||
private val notificationClient: NotificationClient = FakeFfiNotificationClient(),
|
||||
private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(),
|
||||
private val encryption: Encryption = FakeFfiEncryption(),
|
||||
@@ -56,6 +58,7 @@ class FakeFfiClient(
|
||||
) : Client(NoHandle) {
|
||||
override fun userId(): String = userId
|
||||
override fun deviceId(): String = deviceId
|
||||
override fun homeserver(): String = homeserver
|
||||
override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient
|
||||
override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings
|
||||
override fun encryption(): Encryption = encryption
|
||||
|
||||
+1
@@ -77,6 +77,7 @@ class FakeMatrixClient(
|
||||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
|
||||
override val deviceId: DeviceId = A_DEVICE_ID,
|
||||
override val homeserverUrl: String = A_HOMESERVER_URL,
|
||||
override val sessionCoroutineScope: CoroutineScope = TestScope(),
|
||||
private val userDisplayName: String? = A_USER_NAME,
|
||||
private val userAvatarUrl: String? = AN_AVATAR_URL,
|
||||
|
||||
+16
@@ -9,7 +9,23 @@
|
||||
package io.element.android.libraries.permissions.api
|
||||
|
||||
sealed interface PermissionsEvent {
|
||||
/**
|
||||
* Request the runtime permission. If the app has already recorded a soft denial for this
|
||||
* permission, this event is intercepted to raise [PermissionsState.showDialog] instead of
|
||||
* hitting the OS — the intent is for the caller to render `PermissionsView` to prompt the
|
||||
* user to open system settings.
|
||||
*
|
||||
* If the caller renders its own "already denied" UI and wants the OS prompt regardless,
|
||||
* use [ForceRequestPermissions].
|
||||
*/
|
||||
data object RequestPermissions : PermissionsEvent
|
||||
|
||||
/**
|
||||
* Same as [RequestPermissions] but bypasses the soft-denial intercept — always launches
|
||||
* the OS permission dialog. The OS may still auto-deny for permanently-denied permissions.
|
||||
*/
|
||||
data object ForceRequestPermissions : PermissionsEvent
|
||||
|
||||
data object CloseDialog : PermissionsEvent
|
||||
data object OpenSystemSettingAndCloseDialog : PermissionsEvent
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.permissions.api.localnetwork
|
||||
|
||||
interface LocalNetworkPermissionAdvisor {
|
||||
/**
|
||||
* Returns true when the app should request the ACCESS_LOCAL_NETWORK permission before making
|
||||
* network requests to [homeserverUrl].
|
||||
*/
|
||||
suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.permissions.api.localnetwork
|
||||
|
||||
/**
|
||||
* Which rationale dialog (if any) should be rendered on top of the caller's UI.
|
||||
*
|
||||
* [Rationale] is shown before the runtime prompt so the user knows why the permission is needed.
|
||||
* [Settings] is shown when the OS says a rationale can no longer be shown (permanently denied).
|
||||
*/
|
||||
enum class LocalNetworkPermissionDialog {
|
||||
None,
|
||||
Rationale,
|
||||
Settings,
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.permissions.api.localnetwork
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun LocalNetworkPermissionDialogView(
|
||||
dialog: LocalNetworkPermissionDialog,
|
||||
onSubmit: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val submitTextRes = when (dialog) {
|
||||
LocalNetworkPermissionDialog.None -> return
|
||||
LocalNetworkPermissionDialog.Rationale -> CommonStrings.dialog_allow_access
|
||||
LocalNetworkPermissionDialog.Settings -> CommonStrings.action_open_settings
|
||||
}
|
||||
ConfirmationDialog(
|
||||
title = stringResource(CommonStrings.screen_local_network_opt_in_title),
|
||||
content = stringResource(CommonStrings.screen_local_network_opt_in_subtitle),
|
||||
submitText = stringResource(submitTextRes),
|
||||
cancelText = stringResource(CommonStrings.action_not_now),
|
||||
onSubmitClick = onSubmit,
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
+3
@@ -112,6 +112,9 @@ class DefaultPermissionsPresenter(
|
||||
permissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
PermissionsEvent.ForceRequestPermissions -> {
|
||||
permissionState.launchPermissionRequest()
|
||||
}
|
||||
PermissionsEvent.OpenSystemSettingAndCloseDialog -> {
|
||||
permissionActions.openSettings(permission)
|
||||
showDialog.value = false
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.permissions.impl.localnetwork
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier
|
||||
import io.element.android.libraries.androidutils.network.LocalNetworkClassification
|
||||
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLocalNetworkPermissionAdvisor(
|
||||
private val classifier: LocalNetworkAddressClassifier,
|
||||
private val permissionStateProvider: PermissionStateProvider,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : LocalNetworkPermissionAdvisor {
|
||||
override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean {
|
||||
if (!buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.CINNAMON_BUN)) return false
|
||||
if (permissionStateProvider.isPermissionGranted(Manifest.permission.ACCESS_LOCAL_NETWORK)) return false
|
||||
return when (classifier.classify(homeserverUrl)) {
|
||||
LocalNetworkClassification.LocalIp -> true
|
||||
LocalNetworkClassification.PublicIp,
|
||||
// Unresolvable: we can't confirm the host is local, so we don't prompt, acceptable trade-off vs. over-prompting.
|
||||
LocalNetworkClassification.Unresolvable -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.permissions.impl.localnetwork
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier
|
||||
import io.element.android.libraries.androidutils.network.LocalNetworkClassification
|
||||
import io.element.android.libraries.permissions.test.FakePermissionStateProvider
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
private const val LOCAL_URL = "https://matrix.corp.internal"
|
||||
private const val PUBLIC_URL = "https://matrix.org"
|
||||
|
||||
class DefaultLocalNetworkPermissionAdvisorTest {
|
||||
private fun advisor(
|
||||
sdkInt: Int,
|
||||
permissionGranted: Boolean,
|
||||
classification: LocalNetworkClassification,
|
||||
) = DefaultLocalNetworkPermissionAdvisor(
|
||||
classifier = FakeLocalNetworkAddressClassifier(classification),
|
||||
permissionStateProvider = FakePermissionStateProvider(permissionGranted = permissionGranted),
|
||||
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `returns false when SDK below 37`() = runTest {
|
||||
val result = advisor(sdkInt = 36, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
|
||||
.shouldRequestPermissionFor(LOCAL_URL)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns false when permission already granted`() = runTest {
|
||||
val result = advisor(sdkInt = 37, permissionGranted = true, classification = LocalNetworkClassification.LocalIp)
|
||||
.shouldRequestPermissionFor(LOCAL_URL)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns false when URL classified as public IP`() = runTest {
|
||||
val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.PublicIp)
|
||||
.shouldRequestPermissionFor(PUBLIC_URL)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns true when SDK 37, permission missing, URL is local`() = runTest {
|
||||
val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
|
||||
.shouldRequestPermissionFor(LOCAL_URL)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns false when SDK 37, permission missing, URL unresolvable`() = runTest {
|
||||
val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.Unresolvable)
|
||||
.shouldRequestPermissionFor(LOCAL_URL)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns true when SDK above 37, permission missing, URL is local`() = runTest {
|
||||
val result = advisor(sdkInt = 38, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
|
||||
.shouldRequestPermissionFor(LOCAL_URL)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocalNetworkAddressClassifier(
|
||||
private val classification: LocalNetworkClassification,
|
||||
) : LocalNetworkAddressClassifier {
|
||||
override suspend fun classify(url: String): LocalNetworkClassification = classification
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.libraries.permissions.test
|
||||
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
|
||||
class FakeLocalNetworkPermissionAdvisor(
|
||||
private val shouldPrompt: Boolean = false,
|
||||
) : LocalNetworkPermissionAdvisor {
|
||||
override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean = shouldPrompt
|
||||
}
|
||||
+1
@@ -21,6 +21,7 @@ class FakePermissionsPresenter(
|
||||
private fun handleEvent(event: PermissionsEvent) {
|
||||
when (event) {
|
||||
PermissionsEvent.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true)
|
||||
PermissionsEvent.ForceRequestPermissions -> state.value = state.value.copy(permissionAlreadyAsked = true)
|
||||
PermissionsEvent.CloseDialog -> state.value = state.value.copy(showDialog = false)
|
||||
PermissionsEvent.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@
|
||||
<resources>
|
||||
<string name="action_mark_as_read">"Mark as read"</string>
|
||||
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
|
||||
<string name="screen_local_network_opt_in_title">Allow access to local network</string>
|
||||
<string name="screen_local_network_opt_in_subtitle">Your homeserver is on your local network. To connect, Element needs permission to reach other devices on this network.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:924b97967296d3f50e1f911a14bd63b9d5d0d3d5d171053c1a0e6a5048c4d336
|
||||
size 34048
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f98480a126f3cd955fc10e42404d8aa812e2f7fb59a9655593bbebb1c8fa972
|
||||
size 33249
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c89fc51cf4c9a8b623bb7582aa1490109220cec3b0bfc9ce58e150c5a7702029
|
||||
size 32158
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:794613b9e61283cab807a35b23875b4ea9c1198789c844958c755699279fa3b7
|
||||
size 31413
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee3174b63b166511e6c51725a8b6fe00b31eafff011f9059f2f728b77823b465
|
||||
size 33670
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7910112f7ab526dbf0e5326213e03c3f3fe109dc8cc1bd3a8bf5fb05ddb5b0a7
|
||||
size 32054
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884
|
||||
size 255933
|
||||
oid sha256:1885bbe8b0ea497ac20d7bcdd0c6a93ba0533e898378d35e1cafdc8c9924fd56
|
||||
size 279984
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fe6cb72de5079be2ac02ec39f74bac0d20492cd6f5509b71ad9cfc4cc52955d3
|
||||
size 326468
|
||||
oid sha256:1ca032575e4ebd75a2ebc5a4cb9f966e48cdfa56f4ff42d6d9309fa3577a1adb
|
||||
size 310609
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3274314ab9e39c23a6b9d66adf80ac8bb7b364ce60a300f96582374a18bc821f
|
||||
size 54590
|
||||
oid sha256:3dbb8ef39b6a7a0c2f576d8bd101aa97c89da696f23e6d5836846494471c01a1
|
||||
size 55799
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e27c7c51fe07cca51b92a601aa9c58deab609bfa5e82f8f6f88eaab6ae59ec3
|
||||
size 64307
|
||||
oid sha256:f1dd0da147212e13a6c0ad5ad8f85ce55666c444076c238ea27eba5605cfab1f
|
||||
size 56909
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:65f9ee083a73669a0d3f9d88141a610ea53f3c39e89d8f706d414b04555b4764
|
||||
size 59321
|
||||
oid sha256:085963fcb425147d35a9ad947a7a3e4ba4712bd3d2f32e93099ff15cffe83b88
|
||||
size 64761
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a94a4cc176b5d52ef9b21339749324adeef9857b6cdaa2ae973903e2408662a
|
||||
size 55904
|
||||
oid sha256:7a3667390d9d88894bbdd5d72b0b714b29c9ff160653e310e97b0da87b6ba8b0
|
||||
size 54724
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6f7f7415abbe51858f938e16ad03c6aab983fb3d5e2a53a028bae3c031446646
|
||||
size 67848
|
||||
oid sha256:28512b0fda0b71b98eab03cee15c9d3163825b1a3545670cfa277ddd5434c87a
|
||||
size 58417
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a8f5aaaa7ebb0e61b07867590d99a8791c5d04cad1ce0f334d23dce6f26c6e6
|
||||
size 67276
|
||||
oid sha256:6ed6a5f1a2055e19a6b8fb0af4738845bd2184f755c323022c52154cdfa07e05
|
||||
size 60909
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
oid sha256:f77d8243c23d3f37dff86dec53671cbde9fafe1314f6e6b445abdfe47d751ad6
|
||||
size 56187
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:87193a7c9eb9d40234970e5f5be02e80f2b3ffe91e2c9ca6afd58b8bccdd5911
|
||||
size 55075
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
oid sha256:49f0bb16d3d57b59242e704e3dd4e8885c59721a42129837ff6a4f0d7d02de11
|
||||
size 54433
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
oid sha256:8ebee4aec3dbab6e1bb05fda7c5e423f0e931560743d0f0ccbac6b70fa9ad808
|
||||
size 52921
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
|
||||
size 5325
|
||||
oid sha256:c5572491b675a16724cd9e33ebb477c82edf285fa07d57ebef09c670fcaaddf1
|
||||
size 21285
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b
|
||||
size 20793
|
||||
oid sha256:290a2be6838445739b0181006f9fea87337a85cfed3573c153e9f503c66624a6
|
||||
size 4994
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13
|
||||
size 295298
|
||||
oid sha256:4420dfe9f6f95411093eb1c17825a389a31d7a5d5ef146129e5872cdd5d87aff
|
||||
size 295295
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031
|
||||
size 294992
|
||||
oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c
|
||||
size 295031
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
|
||||
size 612494
|
||||
oid sha256:3f358da2b9b0521c14e525bcb2bd41c29c96aacaf5f007546e6d362bde2ee3fb
|
||||
size 612526
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3
|
||||
size 611488
|
||||
oid sha256:a709de55bc68cf12064ebdbde92c0a99bef3252958d7966540458d253e8c12c2
|
||||
size 611422
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399
|
||||
size 607806
|
||||
oid sha256:54e71f45db31e8664c42f8c05feb91ae839589e00cb89d12088c15c85fb9f25f
|
||||
size 607824
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74
|
||||
size 606823
|
||||
oid sha256:8bb505fe6d67669339c9366f8ee5bc75974e223837d95e76e637455a51c18711
|
||||
size 606787
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ff839731834d7d0c94fa9a2c412014ddbaeaa053c5af1c8cbd00a3f3a583f364
|
||||
size 379511
|
||||
oid sha256:94f89a37549a3f198442db467bc7d8539f0b5649d97927bfe731f090d6dfb03d
|
||||
size 379505
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:04ea6c2947a21d3d4a3d43e9c4750c4f2a49056536a4da3f34c95df8353e9de3
|
||||
size 377990
|
||||
oid sha256:d0e170b4bea262de612bf3374d475fcd25470ff91b7554d20809c51857a78cec
|
||||
size 377963
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8bb5a6de6601da8d7fe59f110a054577e2e25dae4bb449f60c04e7d7a1b9551
|
||||
size 365073
|
||||
oid sha256:1a94d5198e27338675c0e15df5874ba3272ee315b8d77291dc7ccc7c754ed1f4
|
||||
size 365082
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:76e5ae44362792d570cd82f3910c8bd0acde509b8418c5892e0385e7d636f042
|
||||
size 370223
|
||||
oid sha256:8344427a0fe87cbf0883ff53a9ee4dcf90976a7011f6618d85961df48b2c53a9
|
||||
size 370228
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:00fe1e676086766a3eac3e4457eff52c989789fc5d8d0df2624c019ed25ce9af
|
||||
size 363322
|
||||
oid sha256:60b9f2b920cd2a2a414805d1f0f77925232d9ae60bd607fa18d4395cda2dd73d
|
||||
size 363291
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f0d24b8011564ee7a8b2f8c2e6a24eda668ee42d451d862f884acb7926aa06c
|
||||
size 368529
|
||||
oid sha256:c00e504593d8eb61b598f05f46ee4d591757975a80c0b4227b240847d2cd4868
|
||||
size 368510
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7c609a633ab23eae5729ecb57a17a385bac65e81cbf0180c01e0847f1028039e
|
||||
size 347890
|
||||
oid sha256:36d7cf2510d546d2b330b1d1b1674ce58df0558c547e443794a1e7b2222add87
|
||||
size 347897
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:991deacdfb7bc371a2df960a2364ae3b2187e588aa5afe7f39409e367040dd9c
|
||||
size 363893
|
||||
oid sha256:a4ac79f3b8fb4e231244bd96b244a4f2526d6ed28d558f9d50734d83bbb74992
|
||||
size 363909
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e30686c7c037ef102717e0316543faf4d3ed2671c21eca96a667150daab380d6
|
||||
size 346614
|
||||
oid sha256:787151f8f242a8794a804bf14831f6ca680814d617e85feb3652103e8a0f20cd
|
||||
size 346565
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user