diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 768d1a5d50..efc7fdc8a1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -57,12 +57,14 @@ class ShareLocationStateProvider : PreviewParameterProvider ) } -private fun aShareLocationState( +fun aShareLocationState( currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), - dialogState: ShareLocationState.Dialog, - trackUserPosition: Boolean, - hasLocationPermission: Boolean, + dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, + trackUserPosition: Boolean = false, + hasLocationPermission: Boolean = false, canShareLiveLocation: Boolean = false, + appName: String = APP_NAME, + eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( currentUser = currentUser, @@ -70,7 +72,7 @@ private fun aShareLocationState( trackUserLocation = trackUserPosition, hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, - appName = APP_NAME, - eventSink = {} + appName = appName, + eventSink = eventSink ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 32d862893a..b9311188d2 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -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.PermissionsPresenter 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.libraries.featureflag.test.FakeFeatureFlagService 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.location.AssetType 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.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_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.room.FakeJoinedRoom 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.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -49,9 +52,12 @@ class ShareLocationPresenterTest { private val fakeMessageComposerContext = FakeMessageComposerContext() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val fakeFeatureFlagService = FakeFeatureFlagService() + private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) private fun createShareLocationPresenter( joinedRoom: JoinedRoom = FakeJoinedRoom(), + locationActions: FakeLocationActions = fakeLocationActions, ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter @@ -60,13 +66,14 @@ class ShareLocationPresenterTest { timelineMode = Timeline.Mode.Live, analyticsService = fakeAnalyticsService, messageComposerContext = fakeMessageComposerContext, - locationActions = fakeLocationActions, + locationActions = locationActions, buildMeta = fakeBuildMeta, + featureFlagService = fakeFeatureFlagService, + client = fakeMatrixClient, ) @Test - fun `initial state with permissions granted`() = runTest { - val shareLocationPresenter = createShareLocationPresenter() + fun `initial state with permissions granted and location enabled`() = runTest { fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, @@ -74,25 +81,18 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) - assertThat(initialState.hasLocationPermission).isTrue() - - // 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() + val shareLocationPresenter = createShareLocationPresenter() + shareLocationPresenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.trackUserLocation).isTrue() + assertThat(state.hasLocationPermission).isTrue() + assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @Test - fun `initial state with permissions partially granted`() = runTest { + fun `initial state with permissions partially granted and location enabled`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -104,17 +104,11 @@ class ShareLocationPresenterTest { moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.present() }.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) + assertThat(initialState.trackUserLocation).isTrue() assertThat(initialState.hasLocationPermission).isTrue() - - // 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() + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @@ -131,22 +125,18 @@ class ShareLocationPresenterTest { moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.present() }.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) + assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.hasLocationPermission).isFalse() - - // Click on the button to switch mode - 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() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied) + ) } } @Test - fun `initial state with permissions denied once`() = runTest { + fun `initial state with permissions denied with rationale`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -155,25 +145,62 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) + assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.hasLocationPermission).isFalse() - - // Click on the button to switch mode - 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() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) + ) } } @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() fakePermissionsPresenter.givenState( aPermissionsState( @@ -182,30 +209,21 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) + ) - // Click on the button to switch mode - 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() - - // 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() + initialState.eventSink(ShareLocationEvent.DismissDialog) + val dismissedState = awaitItem() + assertThat(dismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) } } @Test - fun `rationale dialog continue`() = runTest { + fun `RequestPermissions event triggers permission request`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -214,27 +232,20 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.RequestPermissions) - // Click on the button to switch mode - 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() + // Wait for dialog to be dismissed + awaitItem() - // Continue the dialog sends permission request to the permissions presenter - myLocationState.eventSink(ShareLocationEvent.StartTrackingUserLocation) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + cancelAndIgnoreRemainingEvents() } } @Test - fun `permission denied dialog dismiss`() = runTest { + fun `OpenAppSettings event opens settings and clears dialog`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -243,31 +254,94 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.OpenAppSettings) + val settingsOpenedState = awaitItem() - // Click on the button to switch mode - 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() - - // 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() + assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) + assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) } } @Test - fun `share sender location`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + fun `OpenLocationSettings event opens location settings and clears dialog`() = 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.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) } val joinedRoom = FakeJoinedRoom( @@ -283,29 +357,18 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() - // Send location initialState.eventSink( ShareLocationEvent.ShareStaticLocation( - cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = Location( - lat = 3.0, - lon = 4.0, - accuracy = 5.0f, - ) + location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f), + isPinned = false, ) ) - delay(1) // Wait for the coroutine to finish + advanceUntilIdle() sendLocationResult.assertions().isCalledOnce() .with( @@ -326,12 +389,13 @@ class ShareLocationPresenterTest { messageType = Composer.MessageType.LocationUser, ) ) + cancelAndIgnoreRemainingEvents() } } @Test - fun `share pin location`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + fun `ShareStaticLocation sends pinned location`() = runTest { + val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? -> Result.success(Unit) } val joinedRoom = FakeJoinedRoom( @@ -342,39 +406,26 @@ class ShareLocationPresenterTest { val shareLocationPresenter = createShareLocationPresenter(joinedRoom) fakePermissionsPresenter.givenState( aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, + permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { val initialState = awaitItem() - // Send location initialState.eventSink( ShareLocationEvent.ShareStaticLocation( - cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = Location( - lat = 3.0, - lon = 4.0, - accuracy = 5.0f, - ) + location = Location(lat = 1.0, lon = 2.0, accuracy = 3.0f), + isPinned = true, ) ) - delay(1) // Wait for the coroutine to finish - + advanceUntilIdle() sendLocationResult.assertions().isCalledOnce() .with( - value("Location was shared at geo:0.0,1.0"), - value("geo:0.0,1.0"), + value("Location was shared at geo:1.0,2.0;u=3.0"), + value("geo:1.0,2.0;u=3.0"), value(null), value(15), value(AssetType.PIN), @@ -390,107 +441,7 @@ class ShareLocationPresenterTest { messageType = Composer.MessageType.LocationPin, ) ) - } - } - - @Test - fun `composer context passes through analytics`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> - 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") + cancelAndIgnoreRemainingEvents() } } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt new file mode 100644 index 0000000000..a3e221f77e --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt @@ -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() + + @Test + fun `test back action`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setShareLocationView( + state = aShareLocationState( + eventSink = eventsRecorder + ), + navigateUp = callback, + ) + rule.pressBack() + } + } + + @Test + fun `test fab click`() { + val eventsRecorder = EventsRecorder() + 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() + 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() + 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() + 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() + 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() + 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() + 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 AndroidComposeTestRule.setShareLocationView( + state: ShareLocationState, + navigateUp: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Simulate a LocalInspectionMode for MapLibreMap + CompositionLocalProvider(LocalInspectionMode provides true) { + ShareLocationView( + state = state, + navigateUp = navigateUp, + ) + } + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index a49b887a42..b8a32e5912 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -13,8 +13,11 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location 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.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.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.node.TestParentNode @@ -32,21 +35,27 @@ class DefaultShowLocationEntryPointTest { ShowLocationNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { location: Location, description: String? -> - ShowLocationPresenter( + presenterFactory = object : ShowLocationPresenter.Factory { + override fun create(mode: ShowLocationMode) = ShowLocationPresenter( + mode = mode, permissionsPresenterFactory = { FakePermissionsPresenter() }, locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), - location = location, - description = description, + dateFormatter = FakeDateFormatter(), ) }, analyticsService = FakeAnalyticsService(), ) } val inputs = ShowLocationEntryPoint.Inputs( - location = Location(37.4219983, -122.084, 10f), - description = "My location", + mode = ShowLocationMode.Static( + 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( parentNode = parentNode, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index ebbad8e9d1..e1fe3691c0 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -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.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -66,9 +67,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() @@ -84,9 +84,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() @@ -97,9 +96,8 @@ class ShowLocationPresenterTest { fun `emits initial state with location permission`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() @@ -110,9 +108,8 @@ class ShowLocationPresenterTest { fun `emits initial state with partial location permission`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() @@ -121,9 +118,8 @@ class ShowLocationPresenterTest { @Test fun `uses action to share location`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() initialState.eventSink(ShowLocationEvents.Share(location)) @@ -135,9 +131,8 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() @@ -168,9 +163,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -198,10 +192,8 @@ class ShowLocationPresenterTest { shouldShowRationale = true, ) ) - - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -227,9 +219,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -258,9 +249,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -291,9 +281,8 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakeLocationActions.givenLocationEnabled(false) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter(locationActions = fakeLocationActions).present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() @@ -311,9 +300,8 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakeLocationActions.givenLocationEnabled(false) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter(locationActions = fakeLocationActions).present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))