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 {
data class ChangeServer(val accountProvider: AccountProvider) : 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.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,
@@ -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<Unit>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
@@ -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<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 = {}
)
@@ -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
@@ -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),
),
)
}