From a8b3db4621bc98ce6a95493b4b3c21b61d69c4df Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 7 Jul 2026 21:38:03 +0200 Subject: [PATCH] Prompt for local network permission when changing server --- .../impl/changeserver/ChangeServerEvents.kt | 2 + .../changeserver/ChangeServerPresenter.kt | 21 +++-- .../impl/changeserver/ChangeServerState.kt | 2 + .../changeserver/ChangeServerStateProvider.kt | 4 + .../impl/changeserver/ChangeServerView.kt | 10 +++ .../changeserver/ChangeServerPresenterTest.kt | 80 +++++++++++++++++++ 6 files changed, 111 insertions(+), 8 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt index 27e6128dd0..4f0d228dd2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt @@ -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 } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index 4a4fb3ca41..79f76d5fb5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -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 { @Composable override fun present(): ChangeServerState { - val localCoroutineScope = rememberCoroutineScope() - val changeServerAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + val gateState = localNetworkPermissionGate.present( + 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>, - ) = launch { + ) { suspend { defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( title = data.title, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt index eea3ad78cd..a3467b21ba 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -8,9 +8,11 @@ package io.element.android.features.login.impl.changeserver +import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionDialog import io.element.android.libraries.architecture.AsyncData data class ChangeServerState( val changeServerAction: AsyncData, + val localNetworkPermissionDialog: LocalNetworkPermissionDialog, val eventSink: (ChangeServerEvents) -> Unit ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt index 1a94bb5631..693bca34bd 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -10,6 +10,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.features.login.impl.localnetwork.LocalNetworkPermissionDialog import io.element.android.libraries.architecture.AsyncData open class ChangeServerStateProvider : PreviewParameterProvider { @@ -39,12 +40,15 @@ open class ChangeServerStateProvider : PreviewParameterProvider = AsyncData.Uninitialized, + localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None, ) = ChangeServerState( changeServerAction = changeServerAction, + localNetworkPermissionDialog = localNetworkPermissionDialog, eventSink = {} ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt index d6b9c1709b..15b987ce16 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.login.impl.R import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionDialogView import io.element.android.libraries.androidutils.system.openGooglePlay import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.ProgressDialog @@ -121,6 +122,15 @@ fun ChangeServerView( } AsyncData.Uninitialized -> Unit } + LocalNetworkPermissionDialogView( + dialog = state.localNetworkPermissionDialog, + onSubmit = { + eventSink.invoke(ChangeServerEvents.RequestLocalNetworkPermission) + }, + onDismiss = { + eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission) + } + ) } @PreviewsDayNight diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index 274b58ee49..02ee9d5042 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -15,6 +15,8 @@ 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.LocalNetworkPermissionDialog +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 +25,9 @@ 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.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), + ), ) }