mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge pull request #7165 from element-hq/feature/fga/local_network_permission
Handle local network permission - Android 17
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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user