Merge pull request #6996 from element-hq/feature/fga/location_permissions_rework

change(location): ensure permissions are always requested at least once
This commit is contained in:
ganfra
2026-06-10 09:58:20 +02:00
committed by GitHub
11 changed files with 161 additions and 36 deletions
@@ -14,6 +14,7 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi
sealed interface LocationConstraintsCheck {
data object Success : LocationConstraintsCheck
data object PermissionRationale : LocationConstraintsCheck
data object PermissionShouldBeRequested : LocationConstraintsCheck
data object PermissionDenied : LocationConstraintsCheck
data object LocationServiceDisabled : LocationConstraintsCheck
data object NotEnoughPowerLevel : LocationConstraintsCheck
@@ -34,6 +35,7 @@ fun checkLocationConstraints(
}
}
permissionsState.shouldShowRationale -> LocationConstraintsCheck.PermissionRationale
!permissionsState.permissionsAlreadyRequested -> LocationConstraintsCheck.PermissionShouldBeRequested
else -> LocationConstraintsCheck.PermissionDenied
}
}
@@ -41,6 +43,7 @@ fun checkLocationConstraints(
fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
return when (this) {
LocationConstraintsCheck.Success -> LocationConstraintsDialogState.None
LocationConstraintsCheck.PermissionShouldBeRequested -> LocationConstraintsDialogState.None
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
@@ -9,6 +9,10 @@
package io.element.android.features.location.impl.common.permissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@@ -32,11 +36,16 @@ class DefaultPermissionsPresenter(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
override fun present(): PermissionsState {
val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions)
var permissionsRequested by remember { mutableStateOf(false) }
val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions) {
permissionsRequested = true
}
fun handleEvent(event: PermissionsEvents) {
when (event) {
PermissionsEvents.RequestPermissions -> multiplePermissionsState.launchMultiplePermissionRequest()
PermissionsEvents.RequestPermissions -> {
multiplePermissionsState.launchMultiplePermissionRequest()
}
}
}
@@ -47,6 +56,7 @@ class DefaultPermissionsPresenter(
else -> PermissionsState.Permissions.NoneGranted
},
shouldShowRationale = multiplePermissionsState.shouldShowRationale,
permissionsAlreadyRequested = permissionsRequested,
eventSink = ::handleEvent,
)
}
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.common.permissions
data class PermissionsState(
val permissions: Permissions,
val shouldShowRationale: Boolean,
val permissionsAlreadyRequested: Boolean,
val eventSink: (PermissionsEvents) -> Unit,
) {
sealed interface Permissions {
@@ -88,6 +88,8 @@ class ShareLocationPresenter(
var dialogState: ShareLocationState.Dialog by remember {
mutableStateOf(ShareLocationState.Dialog.None)
}
// true when trying to initiate the live location share
var pendingLiveLocationShare by remember { mutableStateOf(false) }
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
val customMapStyleUrl by produceState(AsyncData.Loading()) {
@@ -100,34 +102,55 @@ class ShareLocationPresenter(
val scope = rememberCoroutineScope()
fun checkLocationConstraints() {
// No need to check SendLiveLocationPermissions here
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
val locationConstraints = checkLocationConstraints(
permissionsState = permissionsState,
locationActions = locationActions,
// No need to check SendLiveLocationPermissions here
sendLiveLocationPermissions = SendLiveLocationPermissions.GRANTED
)
if (locationConstraints is LocationConstraintsCheck.PermissionShouldBeRequested) {
permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
}
suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
val constraintsResult = checkLocationConstraints(permissionsState, locationActions, sendLiveLocationPermissions)
return when {
!hasAcceptedDisclaimer -> {
ShareLocationState.Dialog.LiveLocationDisclaimer
}
constraintsResult is LocationConstraintsCheck.Success -> {
val durations = LIVE_LOCATION_DURATIONS.map {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
suspend fun checkLiveLocationConstraints() {
val locationConstraints = checkLocationConstraints(
permissionsState = permissionsState,
locationActions = locationActions,
sendLiveLocationPermissions = sendLiveLocationPermissions,
)
when (locationConstraints) {
LocationConstraintsCheck.Success -> {
val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
dialogState = if (!hasAcceptedDisclaimer) {
ShareLocationState.Dialog.LiveLocationDisclaimer
} else {
val durations = LIVE_LOCATION_DURATIONS.map {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
}
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
}
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
}
else -> {
ShareLocationState.Dialog.Constraints(constraintsResult.toDialogState())
if (locationConstraints is LocationConstraintsCheck.PermissionShouldBeRequested) {
permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
}
}
}
val userLocationState = userLocationStateFactory.create(permissionsState.isAnyGranted)
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
LaunchedEffect(permissionsState) {
if (pendingLiveLocationShare) {
checkLiveLocationConstraints()
} else {
checkLocationConstraints()
}
}
fun handleEvent(event: ShareLocationEvent) {
when (event) {
@@ -136,7 +159,10 @@ class ShareLocationPresenter(
}
ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints()
ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
ShareLocationEvent.DismissDialog -> {
pendingLiveLocationShare = false
dialogState = ShareLocationState.Dialog.None
}
ShareLocationEvent.OpenAppSettings -> {
locationActions.openAppSettings()
dialogState = ShareLocationState.Dialog.None
@@ -146,15 +172,17 @@ class ShareLocationPresenter(
dialogState = ShareLocationState.Dialog.None
}
ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
dialogState = computeLiveLocationDialogState()
pendingLiveLocationShare = true
checkLiveLocationConstraints()
}
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
liveLocationStore.setAcceptedLiveLocationDisclaimer()
.onSuccess {
dialogState = computeLiveLocationDialogState()
checkLiveLocationConstraints()
}
}
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
pendingLiveLocationShare = false
dialogState = ShareLocationState.Dialog.None
startLiveLocationAction.runUpdatingState {
liveLocationShareManager.startShare(
@@ -90,10 +90,22 @@ import kotlinx.coroutines.launch
value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
}
LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) {
dialogState = LocationConstraintsDialogState.None
fun checkLocationConstraints() {
val locationConstraints = checkLocationConstraints(
permissionsState = permissionsState,
locationActions = locationActions,
// No need to check SendLiveLocationPermissions here
sendLiveLocationPermissions = SendLiveLocationPermissions.GRANTED
)
if (locationConstraints is LocationConstraintsCheck.PermissionShouldBeRequested) {
permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
dialogState = locationConstraints.toDialogState()
}
LaunchedEffect(permissionsState) {
checkLocationConstraints()
}
fun handleEvent(event: ShowLocationEvent) {
@@ -103,9 +115,7 @@ import kotlinx.coroutines.launch
}
is ShowLocationEvent.TrackMyLocation -> {
if (event.enabled) {
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
dialogState = locationConstraints.toDialogState()
checkLocationConstraints()
} else {
isTrackMyLocation = false
}
@@ -13,10 +13,12 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
fun aPermissionsState(
permissions: PermissionsState.Permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale: Boolean = false,
permissionsRequested: Boolean = false,
): PermissionsState {
return PermissionsState(
permissions = permissions,
shouldShowRationale = shouldShowRationale,
permissionsAlreadyRequested = permissionsRequested,
eventSink = {},
)
}
@@ -64,10 +64,25 @@ class LocationConstraintsCheckTest {
}
@Test
fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() {
fun `checkLocationConstraints returns PermissionShouldBeRequested when permissions not yet requested`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = false,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionShouldBeRequested)
}
@Test
fun `checkLocationConstraints returns PermissionDenied when permissions already requested and denied without rationale`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = true,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
@@ -20,6 +20,7 @@ class FakePermissionsPresenter : PermissionsPresenter {
private var state = PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsAlreadyRequested = false,
eventSink = ::handleEvent,
)
set(value) {
@@ -160,6 +160,24 @@ class ShareLocationPresenterTest {
}
}
@Test
fun `initial state with permissions not yet requested triggers permission request`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = false,
)
)
shareLocationPresenter.test {
skipItems(2)
cancelAndIgnoreRemainingEvents()
assertThat(fakePermissionsPresenter.events).contains(PermissionsEvents.RequestPermissions)
}
}
@Test
fun `initial state with permissions denied`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
@@ -167,6 +185,7 @@ class ShareLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = true,
)
)
@@ -291,6 +310,7 @@ class ShareLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = true,
)
)
@@ -359,7 +379,12 @@ class ShareLocationPresenterTest {
@Test
fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest {
val presenter = createShareLocationPresenter()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = grantedSendLiveLocationPermissions()
)
)
val presenter = createShareLocationPresenter(joinedRoom = room)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
@@ -462,7 +487,12 @@ class ShareLocationPresenterTest {
@Test
fun `ShowLiveLocationDurationPicker uses the active session disclaimer state`() = runTest {
val joinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = SessionId("@alice:server")))
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = SessionId("@alice:server"),
roomPermissions = grantedSendLiveLocationPermissions()
),
)
createLiveLocationStore(sessionId = SessionId("@bob:server"))
.setAcceptedLiveLocationDisclaimer()
.getOrThrow()
@@ -505,12 +535,13 @@ class ShareLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = true,
)
)
shareLocationPresenter.test {
val initialState = awaitFirstItem()
// Dismiss initial dialog
// Dismiss initial dialog to allow re-triggering it
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
@@ -37,7 +37,6 @@ import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -103,8 +102,8 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.isTrackMyLocation).isFalse()
assertThat(awaitItem().isTrackMyLocation).isFalse()
assertThat(awaitItem().isTrackMyLocation).isTrue()
}
}
@@ -129,15 +128,12 @@ class ShowLocationPresenterTest {
)
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isTrackMyLocation).isFalse()
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val trackMyLocationState = awaitItem()
delay(1)
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
// Swipe the map to switch mode
@@ -201,12 +197,34 @@ class ShowLocationPresenterTest {
}
}
@Test
fun `TrackMyLocation with permissions not yet requested triggers permission request`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = false,
)
)
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
assertThat(fakePermissionsPresenter.events).contains(PermissionsEvents.RequestPermissions)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `permission denied dialog dismiss`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = true,
)
)
@@ -235,6 +253,7 @@ class ShowLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
permissionsRequested = true,
)
)
@@ -21,6 +21,7 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
@@ -48,6 +49,8 @@ class RoomMemberModerationViewTest {
onSelectAction = callback
)
clickOn(R.string.screen_bottom_sheet_manage_room_member_member_user_info)
// Gives time for bottomsheet to hide
mainClock.advanceTimeBy(1_000)
}
}
@@ -217,11 +220,13 @@ class RoomMemberModerationViewTest {
private fun AndroidComposeUiTest<ComponentActivity>.setRoomMemberModerationView(
state: InternalRoomMemberModerationState,
onSelectAction: (ModerationAction, MatrixUser) -> Unit = EnsureNeverCalledWithTwoParams(),
onAvatarClick: ((MatrixUser) -> Unit)? = EnsureNeverCalledWithParam(),
) {
setSafeContent {
RoomMemberModerationView(
state = state,
onSelectAction = onSelectAction,
onAvatarClick = onAvatarClick,
)
}
}