Prompt for local network permission when changing server

This commit is contained in:
ganfra
2026-07-07 21:38:03 +02:00
parent 6f0788fd34
commit a8b3db4621
6 changed files with 111 additions and 8 deletions
@@ -13,4 +13,6 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChangeServerEvents { sealed interface ChangeServerEvents {
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
data object ClearError : ChangeServerEvents data object ClearError : ChangeServerEvents
data object RequestLocalNetworkPermission : ChangeServerEvents
data object DismissLocalNetworkPermission : ChangeServerEvents
} }
@@ -12,50 +12,55 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject @Inject
class ChangeServerPresenter( class ChangeServerPresenter(
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
) : Presenter<ChangeServerState> { ) : Presenter<ChangeServerState> {
@Composable @Composable
override fun present(): ChangeServerState { override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val changeServerAction: MutableState<AsyncData<Unit>> = remember { val changeServerAction: MutableState<AsyncData<Unit>> = remember {
mutableStateOf(AsyncData.Uninitialized) mutableStateOf(AsyncData.Uninitialized)
} }
val gateState = localNetworkPermissionGate.present<AccountProvider>(
urlOf = { provider -> provider.url },
onProceed = { provider -> changeServer(provider, changeServerAction) },
)
fun handleEvent(event: ChangeServerEvents) { fun handleEvent(event: ChangeServerEvents) {
when (event) { when (event) {
is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction)
ChangeServerEvents.ClearError -> changeServerAction.value = AsyncData.Uninitialized ChangeServerEvents.ClearError -> changeServerAction.value = AsyncData.Uninitialized
is ChangeServerEvents.ChangeServer -> gateState.submit(event.accountProvider)
ChangeServerEvents.DismissLocalNetworkPermission -> gateState.abort()
ChangeServerEvents.RequestLocalNetworkPermission -> gateState.requestPermission()
} }
} }
return ChangeServerState( return ChangeServerState(
changeServerAction = changeServerAction.value, changeServerAction = changeServerAction.value,
localNetworkPermissionDialog = gateState.dialog,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private fun CoroutineScope.changeServer( private suspend fun changeServer(
data: AccountProvider, data: AccountProvider,
changeServerAction: MutableState<AsyncData<Unit>>, changeServerAction: MutableState<AsyncData<Unit>>,
) = launch { ) {
suspend { suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = data.title, title = data.title,
@@ -8,9 +8,11 @@
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.changeserver
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
data class ChangeServerState( data class ChangeServerState(
val changeServerAction: AsyncData<Unit>, val changeServerAction: AsyncData<Unit>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit val eventSink: (ChangeServerEvents) -> Unit
) )
@@ -10,6 +10,7 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> { open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
@@ -39,12 +40,15 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
ChangeServerError.UnsupportedServer ChangeServerError.UnsupportedServer
) )
), ),
aChangeServerState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
) )
} }
fun aChangeServerState( fun aChangeServerState(
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized, changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState( ) = ChangeServerState(
changeServerAction = changeServerAction, changeServerAction = changeServerAction,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = {} eventSink = {}
) )
@@ -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.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.androidutils.system.openGooglePlay import io.element.android.libraries.androidutils.system.openGooglePlay
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -121,6 +122,15 @@ fun ChangeServerView(
} }
AsyncData.Uninitialized -> Unit AsyncData.Uninitialized -> Unit
} }
LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog,
onSubmit = {
eventSink.invoke(ChangeServerEvents.RequestLocalNetworkPermission)
},
onDismiss = {
eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission)
}
)
} }
@PreviewsDayNight @PreviewsDayNight
@@ -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.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.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.FakeWellknownRetriever
import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData 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.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails 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.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@@ -43,6 +48,7 @@ class ChangeServerPresenterTest {
createPresenter().test { createPresenter().test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) 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( private fun createPresenter(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
) = ChangeServerPresenter( ) = ChangeServerPresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
@@ -195,5 +271,9 @@ class ChangeServerPresenterTest {
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever, wellknownRetriever = wellknownRetriever,
), ),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
),
) )
} }