Fix and add tests related to location

This commit is contained in:
ganfra 2026-03-12 12:31:51 +01:00
parent 392fa9de9b
commit 8274ef220d
5 changed files with 404 additions and 291 deletions

View file

@ -57,12 +57,14 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
) )
} }
private fun aShareLocationState( fun aShareLocationState(
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog, dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean, trackUserPosition: Boolean = false,
hasLocationPermission: Boolean, hasLocationPermission: Boolean = false,
canShareLiveLocation: Boolean = false, canShareLiveLocation: Boolean = false,
appName: String = APP_NAME,
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState { ): ShareLocationState {
return ShareLocationState( return ShareLocationState(
currentUser = currentUser, currentUser = currentUser,
@ -70,7 +72,7 @@ private fun aShareLocationState(
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
hasLocationPermission = hasLocationPermission, hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation, canShareLiveLocation = canShareLiveLocation,
appName = APP_NAME, appName = appName,
eventSink = {} eventSink = eventSink
) )
} }

View file

@ -20,22 +20,25 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -49,9 +52,12 @@ class ShareLocationPresenterTest {
private val fakeMessageComposerContext = FakeMessageComposerContext() private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions() private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeFeatureFlagService = FakeFeatureFlagService()
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private fun createShareLocationPresenter( private fun createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(), joinedRoom: JoinedRoom = FakeJoinedRoom(),
locationActions: FakeLocationActions = fakeLocationActions,
): ShareLocationPresenter = ShareLocationPresenter( ): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory { permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
@ -60,13 +66,14 @@ class ShareLocationPresenterTest {
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext, messageComposerContext = fakeMessageComposerContext,
locationActions = fakeLocationActions, locationActions = locationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
featureFlagService = fakeFeatureFlagService,
client = fakeMatrixClient,
) )
@Test @Test
fun `initial state with permissions granted`() = runTest { fun `initial state with permissions granted and location enabled`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted, permissions = PermissionsState.Permissions.AllGranted,
@ -74,25 +81,18 @@ class ShareLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.present() shareLocationPresenter.test {
}.test { skipItems(1)
val initialState = awaitItem() val state = awaitItem()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(state.trackUserLocation).isTrue()
assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) assertThat(state.hasLocationPermission).isTrue()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
// Swipe the map to switch mode
initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isTrue()
} }
} }
@Test @Test
fun `initial state with permissions partially granted`() = runTest { fun `initial state with permissions partially granted and location enabled`() = runTest {
val shareLocationPresenter = createShareLocationPresenter() val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
@ -104,17 +104,11 @@ class ShareLocationPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present() shareLocationPresenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.trackUserLocation).isTrue()
assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation)
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
// Swipe the map to switch mode
initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isTrue()
} }
} }
@ -131,22 +125,18 @@ class ShareLocationPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present() shareLocationPresenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
// Click on the button to switch mode ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) )
val myLocationState = awaitItem()
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionDenied)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
} }
} }
@Test @Test
fun `initial state with permissions denied once`() = runTest { fun `initial state with permissions denied with rationale`() = runTest {
val shareLocationPresenter = createShareLocationPresenter() val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
@ -155,25 +145,62 @@ class ShareLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.test {
shareLocationPresenter.present() skipItems(1)
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
// Click on the button to switch mode ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) )
val myLocationState = awaitItem()
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
} }
} }
@Test @Test
fun `rationale dialog dismiss`() = runTest { fun `initial state with location services disabled`() = runTest {
val locationActions = FakeLocationActions(isLocationEnabled = false)
val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
}
}
@Test
fun `StopTrackingUserLocation event sets trackUserLocation to false`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isTrue()
initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation)
val stoppedState = awaitItem()
assertThat(stoppedState.trackUserLocation).isFalse()
}
}
@Test
fun `DismissDialog event clears dialog state`() = runTest {
val shareLocationPresenter = createShareLocationPresenter() val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
@ -182,30 +209,21 @@ class ShareLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.test {
shareLocationPresenter.present() skipItems(1)
}.test {
// Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
// Click on the button to switch mode initialState.eventSink(ShareLocationEvent.DismissDialog)
initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val dismissedState = awaitItem()
val myLocationState = awaitItem() assertThat(dismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
myLocationState.eventSink(ShareLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
} }
@Test @Test
fun `rationale dialog continue`() = runTest { fun `RequestPermissions event triggers permission request`() = runTest {
val shareLocationPresenter = createShareLocationPresenter() val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
@ -214,27 +232,20 @@ class ShareLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.test {
shareLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.RequestPermissions)
// Click on the button to switch mode // Wait for dialog to be dismissed
initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) awaitItem()
val myLocationState = awaitItem()
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter
myLocationState.eventSink(ShareLocationEvent.StartTrackingUserLocation)
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `permission denied dialog dismiss`() = runTest { fun `OpenAppSettings event opens settings and clears dialog`() = runTest {
val shareLocationPresenter = createShareLocationPresenter() val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
@ -243,31 +254,94 @@ class ShareLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.test {
shareLocationPresenter.present() skipItems(1)
}.test {
// Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.OpenAppSettings)
val settingsOpenedState = awaitItem()
// Click on the button to switch mode assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
val myLocationState = awaitItem()
assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionDenied)
assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
myLocationState.eventSink(ShareLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation)
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
} }
@Test @Test
fun `share sender location`() = runTest { fun `OpenLocationSettings event opens location settings and clears dialog`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ -> val locationActions = FakeLocationActions(isLocationEnabled = false)
val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
initialState.eventSink(ShareLocationEvent.OpenLocationSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(locationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
val durationDialogState = awaitItem()
assertThat(durationDialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDuration)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
// Dismiss initial dialog
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
val constraintDialogState = awaitItem()
assertThat(constraintDialogState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `ShareStaticLocation sends user location`() = runTest {
val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? ->
Result.success(Unit) Result.success(Unit)
} }
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
@ -283,29 +357,18 @@ class ShareLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.test {
shareLocationPresenter.present() skipItems(1)
}.test {
// Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
// Send location
initialState.eventSink( initialState.eventSink(
ShareLocationEvent.ShareStaticLocation( ShareLocationEvent.ShareStaticLocation(
cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f),
lat = 0.0, isPinned = false,
lon = 1.0,
zoom = 2.0,
),
location = Location(
lat = 3.0,
lon = 4.0,
accuracy = 5.0f,
)
) )
) )
delay(1) // Wait for the coroutine to finish advanceUntilIdle()
sendLocationResult.assertions().isCalledOnce() sendLocationResult.assertions().isCalledOnce()
.with( .with(
@ -326,12 +389,13 @@ class ShareLocationPresenterTest {
messageType = Composer.MessageType.LocationUser, messageType = Composer.MessageType.LocationUser,
) )
) )
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `share pin location`() = runTest { fun `ShareStaticLocation sends pinned location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ -> val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? ->
Result.success(Unit) Result.success(Unit)
} }
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
@ -342,39 +406,26 @@ class ShareLocationPresenterTest {
val shareLocationPresenter = createShareLocationPresenter(joinedRoom) val shareLocationPresenter = createShareLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState( fakePermissionsPresenter.givenState(
aPermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
) )
moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.test {
shareLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
// Send location
initialState.eventSink( initialState.eventSink(
ShareLocationEvent.ShareStaticLocation( ShareLocationEvent.ShareStaticLocation(
cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( location = Location(lat = 1.0, lon = 2.0, accuracy = 3.0f),
lat = 0.0, isPinned = true,
lon = 1.0,
zoom = 2.0,
),
location = Location(
lat = 3.0,
lon = 4.0,
accuracy = 5.0f,
)
) )
) )
delay(1) // Wait for the coroutine to finish advanceUntilIdle()
sendLocationResult.assertions().isCalledOnce() sendLocationResult.assertions().isCalledOnce()
.with( .with(
value("Location was shared at geo:0.0,1.0"), value("Location was shared at geo:1.0,2.0;u=3.0"),
value("geo:0.0,1.0"), value("geo:1.0,2.0;u=3.0"),
value(null), value(null),
value(15), value(15),
value(AssetType.PIN), value(AssetType.PIN),
@ -390,107 +441,7 @@ class ShareLocationPresenterTest {
messageType = Composer.MessageType.LocationPin, messageType = Composer.MessageType.LocationPin,
) )
) )
} cancelAndIgnoreRemainingEvents()
}
@Test
fun `composer context passes through analytics`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendLocationLambda = sendLocationResult
},
)
val shareLocationPresenter = createShareLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = ""
)
}
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Send location
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition(
lat = 0.0,
lon = 1.0,
zoom = 2.0,
),
location = null
)
)
delay(1) // Wait for the coroutine to finish
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.LocationPin,
)
)
}
}
@Test
fun `open settings activity`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = ""
)
}
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode)
val dialogShownState = awaitItem()
// Open settings
dialogShownState.eventSink(ShareLocationEvent.OpenAppSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `application name is in state`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name")
} }
} }
} }

View file

@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.location.impl.share
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShareLocationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `test back action`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setShareLocationView(
state = aShareLocationState(
eventSink = eventsRecorder
),
navigateUp = callback,
)
rule.pressBack()
}
}
@Test
fun `test fab click`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation)
}
@Test
fun `when permission denied is displayed user can open the settings`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings)
}
@Test
fun `when permission denied is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
fun `when permission rationale is displayed user can request permissions`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions)
}
@Test
fun `when permission rationale is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
fun `when location service disabled is displayed user can open location settings`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings)
}
@Test
fun `when location service disabled is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShareLocationView(
state: ShareLocationState,
navigateUp: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Simulate a LocalInspectionMode for MapLibreMap
CompositionLocalProvider(LocalInspectionMode provides true) {
ShareLocationView(
state = state,
navigateUp = navigateUp,
)
}
}
}

View file

@ -13,8 +13,11 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@ -32,21 +35,27 @@ class DefaultShowLocationEntryPointTest {
ShowLocationNode( ShowLocationNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { location: Location, description: String? -> presenterFactory = object : ShowLocationPresenter.Factory {
ShowLocationPresenter( override fun create(mode: ShowLocationMode) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { FakePermissionsPresenter() }, permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(), locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
location = location, dateFormatter = FakeDateFormatter(),
description = description,
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
) )
} }
val inputs = ShowLocationEntryPoint.Inputs( val inputs = ShowLocationEntryPoint.Inputs(
location = Location(37.4219983, -122.084, 10f), mode = ShowLocationMode.Static(
description = "My location", location = Location(37.4219983, -122.084, 10f),
senderName = "Alice",
senderId = UserId("@alice:matrix.org"),
senderAvatarUrl = null,
timestamp = System.currentTimeMillis(),
assetType = null,
),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
parentNode = parentNode, parentNode = parentNode,

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -66,9 +67,8 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
@ -84,9 +84,8 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
@ -97,9 +96,8 @@ class ShowLocationPresenterTest {
fun `emits initial state with location permission`() = runTest { fun `emits initial state with location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
@ -110,9 +108,8 @@ class ShowLocationPresenterTest {
fun `emits initial state with partial location permission`() = runTest { fun `emits initial state with partial location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
@ -121,9 +118,8 @@ class ShowLocationPresenterTest {
@Test @Test
fun `uses action to share location`() = runTest { fun `uses action to share location`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.Share(location)) initialState.eventSink(ShowLocationEvents.Share(location))
@ -135,9 +131,8 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest { fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
@ -168,9 +163,8 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@ -198,10 +192,8 @@ class ShowLocationPresenterTest {
shouldShowRationale = true, shouldShowRationale = true,
) )
) )
val presenter = createShowLocationPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
createShowLocationPresenter().present()
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@ -227,9 +219,8 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@ -258,9 +249,8 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter().present() presenter.test {
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@ -291,9 +281,8 @@ class ShowLocationPresenterTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
fakeLocationActions.givenLocationEnabled(false) fakeLocationActions.givenLocationEnabled(false)
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter(locationActions = fakeLocationActions).present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
@ -311,9 +300,8 @@ class ShowLocationPresenterTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
fakeLocationActions.givenLocationEnabled(false) fakeLocationActions.givenLocationEnabled(false)
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
createShowLocationPresenter(locationActions = fakeLocationActions).present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))