From e49e183178d84de9e2d95b75492c2f409a8b13e9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 11 May 2026 10:19:28 +0200 Subject: [PATCH] Feature : share live location (#6741) * First live location sharing sending implementation * Simplify logic around canStop sharing * Add some debug logs around LiveLocationSharingService * Add LiveLocationException * Expose beaconId to identify the current share * Throttle live location instead of debouncing * Keep sync alive when sharing live location * Improve LiveLocation sharing * Show LiveLocationDisclaimer * Read minDistanceUpdate in LiveLocationSharingService * Set minDistanceUpdate in AdvancedSettings * Display banner in room when sharing live location * Fix tests around LiveLocationSharing * Ensure shares are properly restarted/stopped when app is re-launched * Ensure LLS data is cleared when session is removed * Update and fix LLS tests * Handle Start LLS in ui * Add check LLS permissions * Remove hardcoded strings * Fix quality and format * Create DeviceLocationProvider so we can share location data between sources (presenter/live location service) * Update screenshots * Fix warning * Do not try to stop if it was not sharing * Revert "Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)" This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8. * Tweak location provider config values * Address PR review remarks * Fix ktlint * Update screenshots * Fix some tests after merging develop * Adjust TimelineItemLocationView ui to match figma * Update screenshots * Documentation and cleanup * Remove temporary resource --------- Co-authored-by: ElementBot Co-authored-by: Benoit Marty Co-authored-by: Benoit Marty --- .../android/appnav/LoggedInFlowNode.kt | 4 +- .../android/appnav/di/SyncOrchestrator.kt | 10 +- features/location/api/build.gradle.kts | 1 + .../location/api/LiveLocationSharingBanner.kt | 100 ++++ .../live/ActiveLiveLocationShareManager.kt | 43 ++ features/location/impl/build.gradle.kts | 9 + .../impl/src/main/AndroidManifest.xml | 10 + .../impl/common/LocationConstraintsCheck.kt | 4 + .../common/SendLiveLocationPermissions.kt | 34 ++ .../common/ui/LocationConstraintsDialog.kt | 7 + .../impl/common/ui/LocationShareRow.kt | 25 +- .../impl/common/ui/UserLocationPuck.kt | 8 +- .../location/impl/di/LocationBindings.kt | 17 + .../DefaultActiveLiveLocationShareManager.kt | 227 ++++++++ .../location/impl/live/LiveLocationStore.kt | 94 ++++ .../LiveLocationSharingNotificationCreator.kt | 61 +++ .../impl/live/service/LiveLocationReceiver.kt | 14 + .../service/LiveLocationSharingCoordinator.kt | 98 ++++ .../service/LiveLocationSharingService.kt | 125 +++++ .../location/impl/share/ShareLocationEvent.kt | 3 +- .../impl/share/ShareLocationPresenter.kt | 62 ++- .../location/impl/share/ShareLocationState.kt | 3 + .../impl/share/ShareLocationStateProvider.kt | 15 + .../location/impl/share/ShareLocationView.kt | 58 ++- .../location/impl/show/ShowLocationEvent.kt | 1 + .../impl/show/ShowLocationPresenter.kt | 13 +- .../location/impl/show/ShowLocationState.kt | 5 +- .../impl/show/ShowLocationStateProvider.kt | 2 + .../location/impl/show/ShowLocationView.kt | 1 + .../common/LocationConstraintsCheckTest.kt | 22 +- ...faultActiveLiveLocationShareManagerTest.kt | 488 ++++++++++++++++++ .../LiveLocationSharingCoordinatorTest.kt | 115 +++++ .../DefaultShareLocationEntryPointTest.kt | 14 +- .../impl/share/ShareLocationPresenterTest.kt | 247 ++++++++- .../impl/share/ShareLocationViewTest.kt | 32 ++ .../show/DefaultShowLocationEntryPointTest.kt | 5 +- .../show/LiveLocationShareComparatorTest.kt | 14 +- .../impl/show/ShowLocationPresenterTest.kt | 27 +- .../impl/store/LiveLocationStoreTest.kt | 129 +++++ .../FakeActiveLiveLocationShareManager.kt | 46 ++ .../features/messages/impl/MessagesEvent.kt | 2 + .../messages/impl/MessagesFlowNode.kt | 8 + .../messages/impl/MessagesNavigator.kt | 1 + .../features/messages/impl/MessagesNode.kt | 5 + .../messages/impl/MessagesPresenter.kt | 18 + .../features/messages/impl/MessagesState.kt | 1 + .../messages/impl/MessagesStateProvider.kt | 3 + .../features/messages/impl/MessagesView.kt | 82 +-- .../actionlist/ActionListStateProvider.kt | 5 +- .../impl/threads/ThreadedMessagesNode.kt | 6 + .../impl/timeline/TimelinePresenter.kt | 6 +- .../components/TimelineItemEventRow.kt | 2 +- .../event/TimelineItemLocationView.kt | 17 +- .../di/FakeTimelineItemPresenterFactories.kt | 8 + .../event/TimelineItemContentFactory.kt | 1 + .../event/TimelineItemEventContentProvider.kt | 4 +- .../event/TimelineItemLocationContent.kt | 3 +- .../TimelineItemLocationContentProvider.kt | 50 +- .../messages/impl/FakeMessagesNavigator.kt | 5 + .../messages/impl/MessagesPresenterTest.kt | 37 ++ .../messages/impl/MessagesViewTest.kt | 38 ++ .../impl/timeline/TimelinePresenterTest.kt | 3 + .../impl/advanced/AdvancedSettingsEvents.kt | 1 + .../impl/advanced/AdvancedSettingsNode.kt | 6 +- .../advanced/AdvancedSettingsPresenter.kt | 20 + .../impl/advanced/AdvancedSettingsState.kt | 1 + .../advanced/AdvancedSettingsStateProvider.kt | 2 + .../impl/advanced/AdvancedSettingsView.kt | 97 +++- .../advanced/AdvancedSettingsPresenterTest.kt | 69 ++- .../impl/advanced/AdvancedSettingsViewTest.kt | 2 + .../libraries/matrix/api/MatrixClient.kt | 2 + .../matrix/api/room/location/BeaconId.kt | 12 + .../api/room/location/BeaconInfoUpdate.kt | 16 + .../room/location/LiveLocationException.kt | 14 + .../api/room/location/LiveLocationShare.kt | 2 + .../libraries/matrix/impl/RustMatrixClient.kt | 12 + .../matrix/impl/room/JoinedRustRoom.kt | 12 + .../impl/room/location/BeaconInfoUpdates.kt | 21 + .../room/location/LiveLocationException.kt | 19 + .../room/location/LiveLocationSharesFlow.kt | 10 +- .../TimedLiveLocationSharesFlowTest.kt | 27 +- .../libraries/matrix/test/FakeMatrixClient.kt | 2 + .../matrix/test/room/FakeJoinedRoom.kt | 2 +- .../test/room/location/LiveLocationFixture.kt | 38 ++ .../api/store/AppPreferencesStore.kt | 3 + libraries/preferences/impl/build.gradle.kts | 3 + .../impl/store/DefaultAppPreferencesStore.kt | 14 + .../store/DefaultAppPreferencesStoreTest.kt | 57 ++ .../test/InMemoryAppPreferencesStore.kt | 10 + .../notifications/NotificationIdProvider.kt | 1 + .../test/observer/FakeSessionObserver.kt | 5 +- .../api/AppForegroundStateService.kt | 4 + .../impl/DefaultAppForegroundStateService.kt | 6 + .../test/FakeAppForegroundStateService.kt | 7 + ...api_LiveLocationSharingBanner_Day_0_en.png | 3 + ...i_LiveLocationSharingBanner_Night_0_en.png | 3 + ...pl.common.ui_LocationShareRow_Day_0_en.png | 4 +- ....common.ui_LocationShareRow_Night_0_en.png | 4 +- ....impl.share_ShareLocationView_Day_6_en.png | 4 +- ....impl.share_ShareLocationView_Day_7_en.png | 3 + ....impl.share_ShareLocationView_Day_8_en.png | 3 + ...mpl.share_ShareLocationView_Night_6_en.png | 4 +- ...mpl.share_ShareLocationView_Night_7_en.png | 3 + ...mpl.share_ShareLocationView_Night_8_en.png | 3 + ...vent_TimelineItemLocationView_Day_1_en.png | 4 +- ...vent_TimelineItemLocationView_Day_2_en.png | 4 +- ...vent_TimelineItemLocationView_Day_3_en.png | 4 +- ...nt_TimelineItemLocationView_Night_1_en.png | 4 +- ...nt_TimelineItemLocationView_Night_2_en.png | 4 +- ...nt_TimelineItemLocationView_Night_3_en.png | 4 +- ...s.messages.impl_MessagesView_Day_10_en.png | 4 +- ...s.messages.impl_MessagesView_Day_11_en.png | 3 + ...es.messages.impl_MessagesView_Day_8_en.png | 4 +- ...es.messages.impl_MessagesView_Day_9_en.png | 4 +- ...messages.impl_MessagesView_Night_10_en.png | 4 +- ...messages.impl_MessagesView_Night_11_en.png | 3 + ....messages.impl_MessagesView_Night_8_en.png | 4 +- ....messages.impl_MessagesView_Night_9_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_0_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_1_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_2_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_3_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_4_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_5_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_6_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_7_en.png | 4 +- ...dvanced_AdvancedSettingsViewBlack_8_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_0_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_1_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_2_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_3_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_4_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_5_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_6_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_7_en.png | 4 +- ...advanced_AdvancedSettingsViewDark_8_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_0_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_1_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_2_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_3_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_4_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_5_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_6_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_7_en.png | 4 +- ...dvanced_AdvancedSettingsViewLight_8_en.png | 4 +- 145 files changed, 2913 insertions(+), 278 deletions(-) create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt create mode 100644 features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconId.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconInfoUpdate.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationException.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/BeaconInfoUpdates.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationException.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/location/LiveLocationFixture.kt create mode 100644 libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 70376ffcb9..f2120038a6 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -54,6 +54,7 @@ import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer @@ -151,6 +152,7 @@ class LoggedInFlowNode( private val analyticsService: AnalyticsService, private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, + private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -211,6 +213,7 @@ class LoggedInFlowNode( super.onBuilt() lifecycleScope.launch { sessionEnterpriseService.init() + activeLiveLocationShareManager.setup() } lifecycle.subscribe( onCreate = { @@ -219,7 +222,6 @@ class LoggedInFlowNode( loggedInFlowProcessor.observeEvents(sessionCoroutineScope) matrixClient.sessionVerificationService.setListener(verificationListener) mediaPreviewConfigMigration() - sessionCoroutineScope.launch { // Wait for the network to be connected before pre-fetching the max file upload size networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt index 9b1bbd1b81..1ce423a569 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt @@ -89,16 +89,14 @@ class SyncOrchestrator( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun observeStates() = coroutineScope.launch { Timber.tag(tag).d("start observing the app and network state") - - val isAppActiveFlow = combine( + val isAppActiveFlows = listOf( appForegroundStateService.isInForeground, appForegroundStateService.isInCall, appForegroundStateService.isSyncingNotificationEvent, appForegroundStateService.hasRingingCall, - ) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall -> - isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall - } - + appForegroundStateService.isSharingLiveLocation + ) + val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } } combine( // small debounce to avoid spamming startSync when the state is changing quickly in case of error. syncService.syncState.debounce(100.milliseconds), diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index ab85e37594..f8377389f1 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(libs.coil.compose) + implementation(libs.datetime) testCommonDependencies(libs) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt new file mode 100644 index 0000000000..8c53550737 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Element Creations 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.api + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LiveLocationSharingBanner( + onClick: () -> Unit, + onStopClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(ElementTheme.colors.bgCanvasDefault) + .drawBannerBorder(ElementTheme.colors.separatorPrimary) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.LocationPinSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(CommonStrings.screen_room_live_location_banner), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } + Button( + text = stringResource(CommonStrings.action_stop), + onClick = onStopClick, + destructive = true, + size = ButtonSize.Small, + ) + } +} + +private fun Modifier.drawBannerBorder(borderColor: Color): Modifier = drawBehind { + val strokeWidth = 1.dp.toPx() + val bottomY = size.height - strokeWidth / 2 + drawLine( + color = borderColor, + start = Offset(0f, strokeWidth / 2), + end = Offset(size.width, strokeWidth / 2), + strokeWidth = strokeWidth, + ) + drawLine( + color = borderColor, + start = Offset(0f, bottomY), + end = Offset(size.width, bottomY), + strokeWidth = strokeWidth, + ) +} + +@PreviewsDayNight +@Composable +internal fun LiveLocationSharingBannerPreview() = ElementPreview { + LiveLocationSharingBanner( + onClick = {}, + onStopClick = {}, + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt new file mode 100644 index 0000000000..cd6b8731c1 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Element Creations 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.api.live + +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration + +interface ActiveLiveLocationShareManager { + /** All rooms currently sharing live location on this device. */ + val sharingRoomIds: StateFlow> + + /** + * Initializes the manager. + * This will restart or stop current location sharing and set the listener on the SDK + * and the session manager. + */ + suspend fun setup() + + /** + * Starts live location sharing in the given room. + * Calls room.startLiveLocationShare() on the SDK, registers the share, + * and starts the foreground GPS service if not already running. + */ + suspend fun startShare(roomId: RoomId, duration: Duration): Result + + /** + * Stops live location sharing in the given room. + * Calls room.stopLiveLocationShare() on the SDK, removes the share, + * and stops the foreground service if no shares remain. + */ + suspend fun stopShare(roomId: RoomId): Result +} + +fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow { + return sharingRoomIds.mapState { roomId in it } +} diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 0da54a1394..165c32b7c5 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -37,10 +37,16 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.matrixui) implementation(projects.services.analytics.api) + implementation(projects.services.appnavstate.api) implementation(libs.accompanist.permission) implementation(projects.libraries.uiStrings) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.sessionStorage.api) + implementation(libs.androidx.datastore.preferences) + implementation(libs.datetime) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) @@ -50,4 +56,7 @@ dependencies { testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.features.location.test) } diff --git a/features/location/impl/src/main/AndroidManifest.xml b/features/location/impl/src/main/AndroidManifest.xml index ae728c09e1..e92ca68077 100644 --- a/features/location/impl/src/main/AndroidManifest.xml +++ b/features/location/impl/src/main/AndroidManifest.xml @@ -9,4 +9,14 @@ + + + + + + + diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt index a0b0cd4734..f90793b775 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt @@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck { data object PermissionRationale : LocationConstraintsCheck data object PermissionDenied : LocationConstraintsCheck data object LocationServiceDisabled : LocationConstraintsCheck + data object NotEnoughPowerLevel : LocationConstraintsCheck } fun checkLocationConstraints( permissionsState: PermissionsState, locationActions: LocationActions, + sendLiveLocationPermissions: SendLiveLocationPermissions, ): LocationConstraintsCheck { return when { + !sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel permissionsState.isAnyGranted -> { if (locationActions.isLocationEnabled()) { LocationConstraintsCheck.Success @@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState { LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled + LocationConstraintsCheck.NotEnoughPowerLevel -> LocationConstraintsDialogState.NotEnoughPowerLevel } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt new file mode 100644 index 0000000000..d1a9e32026 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Element Creations 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.common + +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +/** + * Permissions to send beacon and beacon_info events in the room. + */ +data class SendLiveLocationPermissions( + val canSendBeacon: Boolean, + val canSendBeaconInfo: Boolean, +) { + val hasAll = canSendBeaconInfo && canSendBeacon + + companion object { + val DEFAULT = SendLiveLocationPermissions(canSendBeacon = false, canSendBeaconInfo = false) + val GRANTED = SendLiveLocationPermissions(canSendBeacon = true, canSendBeaconInfo = true) + } +} + +fun RoomPermissions.sendLiveLocationPermissions(): SendLiveLocationPermissions { + return SendLiveLocationPermissions( + canSendBeaconInfo = canOwnUserSendState(StateEventType.BeaconInfo), + canSendBeacon = canOwnUserSendMessage(MessageEventType.Beacon), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt index 95f5129f91..334aebaee6 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -10,6 +10,8 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource +import io.element.android.features.location.impl.R +import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.ui.strings.CommonStrings @@ -42,6 +44,10 @@ fun LocationConstraintsDialog( onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), ) + LocationConstraintsDialogState.NotEnoughPowerLevel -> AlertDialog( + content = stringResource(R.string.screen_share_location_live_location_missing_permissions), + onDismiss = onDismiss + ) } } @@ -51,4 +57,5 @@ sealed interface LocationConstraintsDialogState { data object PermissionRationale : LocationConstraintsDialogState data object PermissionDenied : LocationConstraintsDialogState data object LocationServiceDisabled : LocationConstraintsDialogState + data object NotEnoughPowerLevel : LocationConstraintsDialogState } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt index 3d7b8df618..24476e3c66 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun LocationShareRow( item: LocationShareItem, onShareClick: () -> Unit, + onStopClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -101,11 +103,24 @@ fun LocationShareRow( ) } } + if (item.canStopSharing) { + IconButton( + onClick = onStopClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = ElementTheme.colors.bgCriticalPrimary, + contentColor = ElementTheme.colors.iconOnSolidPrimary, + ) + ) { + Icon( + imageVector = CompoundIcons.Stop(), + contentDescription = stringResource(CommonStrings.action_stop), + ) + } + } IconButton(onClick = onShareClick) { Icon( imageVector = CompoundIcons.ShareAndroid(), contentDescription = stringResource(CommonStrings.action_share), - tint = ElementTheme.colors.iconPrimary, ) } } @@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview { formattedTimestamp = "Shared 1 min ago", isLive = true, assetType = AssetType.SENDER, - location = Location(0.0, 0.0) + location = Location(0.0, 0.0), + isOwnUser = true, ), + onStopClick = {}, onShareClick = {}, ) LocationShareRow( @@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview { isLive = false, assetType = AssetType.PIN, formattedTimestamp = "Shared 5 hours ago", - location = Location(0.0, 0.0) + location = Location(0.0, 0.0), + isOwnUser = false ), + onStopClick = {}, onShareClick = {}, ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 8b89f77be4..589ed87c6f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState import org.maplibre.compose.location.rememberAndroidLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState -import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds @Composable fun UserLocationPuck( @@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState rememberNullLocationProvider() } else { rememberAndroidLocationProvider( - updateInterval = 1.minutes, - desiredAccuracy = DesiredAccuracy.Balanced, - minDistanceMeters = 50f, + updateInterval = 5.seconds, + desiredAccuracy = DesiredAccuracy.High, + minDistanceMeters = 5f, ) } return rememberUserLocationState(locationProvider) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt new file mode 100644 index 0000000000..ee70936160 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations 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.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.location.impl.live.service.LiveLocationSharingService + +@ContributesTo(AppScope::class) +interface LocationBindings { + fun inject(service: LiveLocationSharingService) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt new file mode 100644 index 0000000000..fd16bea515 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 Element Creations 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.live + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager +import io.element.android.features.location.impl.live.service.LiveLocationReceiver +import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.location.BeaconId +import io.element.android.libraries.matrix.api.room.location.LiveLocationException +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration +import kotlin.time.Instant + +@OptIn(ExperimentalAtomicApi::class) +@SingleIn(SessionScope::class) +@ContributesBinding(SessionScope::class, binding = binding()) +class DefaultActiveLiveLocationShareManager( + private val matrixClient: MatrixClient, + private val coordinator: LiveLocationSharingCoordinator, + private val liveLocationStore: LiveLocationStore, + private val clock: SystemClock, + private val sessionObserver: SessionObserver, +) : ActiveLiveLocationShareManager, LiveLocationReceiver { + private val isSetup = AtomicBoolean(false) + private val cachedRooms = ConcurrentHashMap() + private val timeoutJobs = ConcurrentHashMap() + private val syncedActiveShareIds = MutableStateFlow>(emptySet()) + private val localSharingRoomIds = MutableStateFlow>(emptySet()) + override val sharingRoomIds: StateFlow> = localSharingRoomIds + + override suspend fun setup() = withContext(NonCancellable) { + if (isSetup.compareAndSet(expectedValue = false, newValue = true)) { + Timber.d("ActiveLiveLocationShareManager setup manager.") + + recoverPersistedShares() + + matrixClient.ownBeaconInfoUpdates + .onEach { update -> + Timber.d("Received beaconInfoUpdate:$update") + // First cancel the local share in this room if any. + if (update.roomId in localSharingRoomIds.value) { + stopLocalShare(roomId = update.roomId) + } + syncedActiveShareIds.update { + if (update.isLive) { + it + update.beaconId + } else { + it - update.beaconId + } + } + } + .launchIn(matrixClient.sessionCoroutineScope) + + sessionObserver.addListener(sessionListener) + } + } + + private val sessionListener: SessionListener = object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + if (matrixClient.sessionId.value == userId) { + clear() + } + } + } + + override suspend fun startShare(roomId: RoomId, duration: Duration): Result = withContext(NonCancellable) { + Timber.d("ActiveLiveLocationShareManager starting share for room $roomId with duration ${duration.inWholeSeconds}s") + val room = cachedRooms.getOrPut(roomId) { + matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId")) + } + // Before starting a new location share, stop the current one if any is active. + room.stopLiveLocationShare() + + room.startLiveLocationShare(duration.inWholeMilliseconds) + .onSuccess { beaconId -> + Timber.d("ActiveLiveLocationShareManager wait remote echo of $beaconId") + syncedActiveShareIds.first { beaconIds -> beaconIds.contains(beaconId) } + val expiresAt = Instant.fromEpochMilliseconds(clock.epochMillis() + duration.inWholeMilliseconds) + startLocalShare(roomId, expiresAt) + } + .onFailure { + Timber.e(it, "ActiveLiveLocationShareManager failed to start share for room $roomId") + stopLocalShare(roomId) + } + .map { } + } + + override suspend fun stopShare(roomId: RoomId): Result = withContext(NonCancellable) { + Timber.d("ActiveLiveLocationShareManager stopping share for room $roomId") + val room = cachedRooms.getOrPut(roomId) { + matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId")) + } + room.stopLiveLocationShare() + .onSuccess { + Timber.d("ActiveLiveLocationShareManager share stopped successfully for room $roomId") + } + .onFailure { + Timber.e(it, "ActiveLiveLocationShareManager failed to stop share for room $roomId") + } + .also { + stopLocalShare(roomId) + } + } + + override suspend fun onLocationUpdate(location: Location) { + val activeSharesCount = localSharingRoomIds.value.size + Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)") + localSharingRoomIds.value.forEach { roomId -> + Timber.d("ActiveLiveLocationShareManager sending location to room $roomId") + sendLiveLocation(roomId, location) + .onFailure { + Timber.e(it, "ActiveLiveLocationShareManager failed to send location to room $roomId") + } + } + } + + private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result { + val room = cachedRooms.getOrPut(roomId) { + matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId")) + } + return room.sendLiveLocation(location.toGeoUri()) + .recoverCatching { exception -> + when (exception) { + is LiveLocationException.NotLive -> { + stopLocalShare(roomId) + throw exception + } + else -> throw exception + } + } + } + + private suspend fun startLocalShare(roomId: RoomId, expiresAt: Instant) { + val wasEmpty = localSharingRoomIds.value.isEmpty() + Timber.d("ActiveLiveLocationShareManager share started successfully for room $roomId (wasEmpty=$wasEmpty)") + localSharingRoomIds.update { it + roomId } + liveLocationStore.setLiveLocationExpiry(roomId, expiresAt) + scheduleTimeout(roomId, expiresAt) + if (wasEmpty) { + Timber.d("ActiveLiveLocationShareManager registering with coordinator for session ${matrixClient.sessionId}") + coordinator.register(matrixClient.sessionId, this@DefaultActiveLiveLocationShareManager) + } + } + + private suspend fun recoverPersistedShares() { + val now = Instant.fromEpochMilliseconds(clock.epochMillis()) + liveLocationStore.getLiveLocationExpiries().forEach { (roomId, expiresAt) -> + if (expiresAt > now) { + // Only starts locally as the share is already started remotely + startLocalShare(roomId, expiresAt) + } else { + // Explicitly stop the share on the server. + stopShare(roomId) + } + } + } + + private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) { + timeoutJobs.remove(roomId)?.cancel() + val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis() + timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch { + delay(delayMillis) + stopShare(roomId) + .onFailure { error -> + Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId") + } + } + } + + private suspend fun stopLocalShare(roomId: RoomId) { + Timber.d("ActiveLiveLocationShareManager stop local share in $roomId") + timeoutJobs.remove(roomId)?.cancel() + val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty() + cachedRooms.remove(roomId)?.close() + liveLocationStore.removeLiveLocationExpiry(roomId) + if (wasSharing && localSharingRoomIds.value.isEmpty()) { + Timber.d("ActiveLiveLocationShareManager unregistering from coordinator for session ${matrixClient.sessionId}") + coordinator.unregister(matrixClient.sessionId) + } + } + + private suspend fun clear() { + Timber.d("ActiveLiveLocationShareManager clear state") + sessionObserver.removeListener(sessionListener) + coordinator.unregister(matrixClient.sessionId) + liveLocationStore.clear() + for (room in cachedRooms.values) { + room.close() + timeoutJobs[room.roomId]?.cancel() + } + timeoutJobs.clear() + cachedRooms.clear() + localSharingRoomIds.value = emptySet() + syncedActiveShareIds.value = emptySet() + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt new file mode 100644 index 0000000000..417d9d423a --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Element Creations 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.live + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.first +import timber.log.Timber +import kotlin.time.Instant + +private const val LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR = "=" + +@Inject +@SingleIn(SessionScope::class) +class LiveLocationStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, + sessionId: SessionId, +) { + private val store = preferenceDataStoreFactory.create("location_${sessionId.value.hash().take(16)}") + private val acceptedLiveLocationDisclaimerKey = booleanPreferencesKey("live_location_disclaimer_accepted") + private val liveLocationExpiriesKey = stringSetPreferencesKey("live_location_expiries") + + suspend fun hasAcceptedLiveLocationDisclaimer(): Boolean = runCatchingExceptions { + store.data.first()[acceptedLiveLocationDisclaimerKey] ?: false + }.getOrDefault(false) + + suspend fun setAcceptedLiveLocationDisclaimer(): Result = runCatchingExceptions { + store.edit { prefs -> + prefs[acceptedLiveLocationDisclaimerKey] = true + } + } + + suspend fun getLiveLocationExpiries(): Map = runCatchingExceptions { + val serialized = store.data.first()[liveLocationExpiriesKey].orEmpty() + decodeLiveLocationExpiries(serialized) + }.onFailure { error -> + Timber.e(error, "Failed to decode live location expiry payload") + }.getOrDefault(emptyMap()) + + suspend fun setLiveLocationExpiry(roomId: RoomId, expiresAt: Instant): Result = runCatchingExceptions { + store.edit { prefs -> + val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty()) + prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt)) + } + } + + suspend fun removeLiveLocationExpiry(roomId: RoomId): Result = runCatchingExceptions { + store.edit { prefs -> + val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty()) + val updated = current - roomId + if (updated.isEmpty()) { + prefs.remove(liveLocationExpiriesKey) + } else { + prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(updated) + } + } + } + + private fun decodeLiveLocationExpiries(serialized: Set): Map { + return runCatchingExceptions { + serialized + .map { it.split(LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR) } + .associate { values -> + val roomId = RoomId(values[0]) + val expiresAtMillis = values[1].toLong() + roomId to Instant.fromEpochMilliseconds(expiresAtMillis) + } + }.getOrDefault(emptyMap()) + } + + private fun encodeLiveLocationExpiries(expiries: Map): Set { + return expiries.entries.map { (roomId, expiresAt) -> + "${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}" + }.toSet() + } + + suspend fun clear() { + store.edit { prefs -> prefs.clear() } + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt new file mode 100644 index 0000000000..9d4c461b0e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Element Creations 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.live.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.ui.strings.CommonStrings + +@Inject +class LiveLocationSharingNotificationCreator( + @ApplicationContext private val context: Context, + private val buildMeta: BuildMeta, +) { + companion object { + const val CHANNEL_ID = "LIVE_LOCATION_SHARING" + } + + fun createNotification(): Notification { + if (supportNotificationChannels()) { + ensureChannelExists() + } + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentTitle(context.getString(CommonStrings.live_location_sharing_foreground_service_title_android, buildMeta.applicationName)) + .setContentText(context.getString(CommonStrings.live_location_sharing_foreground_service_message_android)) + .setOngoing(true) + .build() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun ensureChannelExists() { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(CommonStrings.live_location_sharing_foreground_service_channel_title_android) + .ifEmpty { "Live Location Sharing" }, + NotificationManager.IMPORTANCE_LOW, + ) + ) + } + } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) + private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt new file mode 100644 index 0000000000..adba75730c --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations 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.live.service + +import io.element.android.features.location.api.Location + +fun interface LiveLocationReceiver { + suspend fun onLocationUpdate(location: Location) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt new file mode 100644 index 0000000000..e39acb14e8 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Element Creations 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.live.service + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.features.location.api.Location +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.services.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration.Companion.seconds + +private val THROTTLE_WINDOW = 3.seconds + +@OptIn(ExperimentalAtomicApi::class) +@SingleIn(AppScope::class) +class LiveLocationSharingCoordinator internal constructor( + private val startService: () -> Unit, + private val stopService: () -> Unit, + private val nowMillis: () -> Long, +) { + @Inject + constructor(@ApplicationContext context: Context, clock: SystemClock) : this( + startService = { + ContextCompat.startForegroundService(context, Intent(context, LiveLocationSharingService::class.java)) + }, + stopService = { + context.stopService(Intent(context, LiveLocationSharingService::class.java)) + }, + nowMillis = clock::epochMillis + ) + + private val receivers = ConcurrentHashMap() + + private val lastDispatchMillis = AtomicLong(0L) + private val lastKnownLocation = AtomicReference(null) + + suspend fun register(sessionId: SessionId, receiver: LiveLocationReceiver) { + val wasEmpty = receivers.isEmpty() + Timber.d("LiveLocationSharingCoordinator registering receiver for session $sessionId (wasEmpty=$wasEmpty)") + receivers[sessionId] = receiver + if (wasEmpty) { + Timber.d("LiveLocationSharingCoordinator starting service") + runCatchingExceptions(startService).onFailure { + Timber.e(it, "Failed to start live location sharing service") + } + } + lastKnownLocation.load()?.let { + dispatch(it) + } + } + + fun unregister(sessionId: SessionId) { + Timber.d("LiveLocationSharingCoordinator unregistering receiver for session $sessionId") + receivers.remove(sessionId) + if (receivers.isEmpty()) { + lastKnownLocation.store(null) + Timber.d("LiveLocationSharingCoordinator stopping service (no more receivers)") + runCatchingExceptions(stopService).onFailure { + Timber.e(it, "Failed to stop live location sharing service") + } + } + } + + suspend fun dispatch(location: Location) { + val currentTimeMillis = nowMillis() + val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load() + if (millisSincePrevious < THROTTLE_WINDOW.inWholeMilliseconds) { + Timber.d("Received location before $THROTTLE_WINDOW, ignore.") + return + } + lastKnownLocation.store(location) + lastDispatchMillis.store(currentTimeMillis) + receivers.forEach { (sessionId, receiver) -> + Timber.d("Dispatch received location for session $sessionId ") + runCatchingExceptions { + receiver.onLocationUpdate(location) + }.onFailure { + Timber.e(it, "Failed to dispatch live location update for session $sessionId") + } + } + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt new file mode 100644 index 0000000000..4451febb19 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Element Creations 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.live.service + +import android.annotation.SuppressLint +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION +import android.os.IBinder +import androidx.core.app.ServiceCompat +import dev.zacsweers.metro.Inject +import io.element.android.features.location.impl.di.LocationBindings +import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.push.api.notifications.ForegroundServiceType +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.maplibre.compose.location.AndroidLocationProvider +import org.maplibre.compose.location.DesiredAccuracy +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds +import io.element.android.features.location.api.Location as ApiLocation + +private const val UPDATE_INTERVAL_IN_SECOND = 10 + +class LiveLocationSharingService : Service() { + @Inject lateinit var coordinator: LiveLocationSharingCoordinator + @Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator + @Inject lateinit var appPreferencesStore: AppPreferencesStore + + @Inject lateinit var appForegroundStateService: AppForegroundStateService + + @AppCoroutineScope + @Inject lateinit var appCoroutineScope: CoroutineScope + private lateinit var coroutineScope: CoroutineScope + + override fun onBind(p0: Intent?): IBinder? = null + + @OptIn(FlowPreview::class) + @SuppressLint("InlinedApi") + override fun onCreate() { + super.onCreate() + Timber.d("LiveLocationSharingService onCreate") + runCatchingExceptions { + bindings().inject(this) + appForegroundStateService.updateIsSharingLiveLocation(true) + coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService") + val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION) + Timber.d("LiveLocationSharingService starting foreground service with notificationId=$notificationId") + ServiceCompat.startForeground( + // service = + this, + // id = + notificationId, + // notification = + notificationCreator.createNotification(), + // foregroundServiceType = + FOREGROUND_SERVICE_TYPE_LOCATION, + ) + startLocationUpdatesListener() + }.onFailure { + Timber.e(it, "Failed to start live location sharing service") + stopSelf() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun startLocationUpdatesListener() { + Timber.d("LiveLocationSharingService listening to location updates") + appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() + .flatMapLatest { minDistanceMeters -> + val locationProvider = AndroidLocationProvider( + context = applicationContext, + updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, + minDistanceMeters = minDistanceMeters.toFloat(), + desiredAccuracy = DesiredAccuracy.Balanced, + coroutineScope = coroutineScope + ) + locationProvider.location + } + .filterNotNull() + .map { location -> + ApiLocation( + lat = location.position.latitude, + lon = location.position.longitude, + accuracy = location.accuracy.toFloat(), + ) + } + .onEach(coordinator::dispatch) + .launchIn(coroutineScope) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.d("LiveLocationSharingService onStartCommand startId=$startId") + return START_STICKY + } + + override fun onDestroy() { + Timber.d("LiveLocationSharingService onDestroy") + if (::coroutineScope.isInitialized) { + coroutineScope.cancel() + } + appForegroundStateService.updateIsSharingLiveLocation(false) + super.onDestroy() + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index d9ebc8b5af..e560ce805f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -17,7 +17,8 @@ sealed interface ShareLocationEvent { val isPinned: Boolean, ) : ShareLocationEvent - data object ShowLiveLocationDurationPicker : ShareLocationEvent + data object InitiateLiveLocationShare : ShareLocationEvent + data object AcceptLiveLocationDisclaimer : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent data object StartTrackingUserLocation : ShareLocationEvent diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 10fddf1e50..b56f9c7cb3 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -21,17 +21,22 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.SendLiveLocationPermissions import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.checkLocationConstraints 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.sendLiveLocationPermissions import io.element.android.features.location.impl.common.toDialogState -import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints +import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.dateformatter.api.DurationFormatter @@ -41,6 +46,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.room.CreateTimelineParams 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.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService @@ -63,6 +69,8 @@ class ShareLocationPresenter( private val featureFlagService: FeatureFlagService, private val client: MatrixClient, private val durationFormatter: DurationFormatter, + private val liveLocationShareManager: ActiveLiveLocationShareManager, + private val liveLocationStore: LiveLocationStore, ) : Presenter { @AssistedFactory fun interface Factory { @@ -82,15 +90,39 @@ class ShareLocationPresenter( var dialogState: ShareLocationState.Dialog by remember { mutableStateOf(ShareLocationState.Dialog.None) } + val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val currentUser by client.userProfile.collectAsState() + val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> + perms.sendLiveLocationPermissions() + } val scope = rememberCoroutineScope() fun checkLocationConstraints() { - val locationConstraints = checkLocationConstraints(permissionsState, locationActions) - dialogState = Constraints(locationConstraints.toDialogState()) + // No need to check SendLiveLocationPermissions here + val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) + dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState()) trackUserPosition = locationConstraints is LocationConstraintsCheck.Success } + 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)) + } + ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList()) + } + else -> { + ShareLocationState.Dialog.Constraints(constraintsResult.toDialogState()) + } + } + } + LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() } fun handleEvent(event: ShareLocationEvent) { @@ -109,20 +141,23 @@ class ShareLocationPresenter( locationActions.openLocationSettings() dialogState = ShareLocationState.Dialog.None } - ShareLocationEvent.ShowLiveLocationDurationPicker -> { - val constraintsResult = checkLocationConstraints(permissionsState, locationActions) - dialogState = if (constraintsResult is LocationConstraintsCheck.Success) { - val durations = LIVE_LOCATION_DURATIONS.map { - LiveLocationDuration(duration = it, formatted = durationFormatter.format(it)) + ShareLocationEvent.InitiateLiveLocationShare -> scope.launch { + dialogState = computeLiveLocationDialogState() + } + ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch { + liveLocationStore.setAcceptedLiveLocationDisclaimer() + .onSuccess { + dialogState = computeLiveLocationDialogState() } - ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList()) - } else { - Constraints(constraintsResult.toDialogState()) - } } is ShareLocationEvent.StartLiveLocationShare -> scope.launch { dialogState = ShareLocationState.Dialog.None - // room.startLiveLocationShare(event.duration.inWholeMilliseconds) + startLiveLocationAction.runUpdatingState { + liveLocationShareManager.startShare( + roomId = room.roomId, + duration = event.duration, + ) + } } ShareLocationEvent.RequestPermissions -> { dialogState = ShareLocationState.Dialog.None @@ -138,6 +173,7 @@ class ShareLocationPresenter( hasLocationPermission = permissionsState.isAnyGranted, canShareLiveLocation = isLiveLocationSharingEnabled, appName = appName, + startLiveLocationAction = startLiveLocationAction.value, eventSink = ::handleEvent, ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 8b1f494f1e..68598cba04 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.share import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -19,11 +20,13 @@ data class ShareLocationState( val hasLocationPermission: Boolean, val appName: String, val canShareLiveLocation: Boolean, + val startLiveLocationAction: AsyncAction, val eventSink: (ShareLocationEvent) -> Unit, ) { sealed interface Dialog { data object None : Dialog data class Constraints(val state: LocationConstraintsDialogState) : Dialog + data object LiveLocationDisclaimer : Dialog data class LiveLocationDurations(val durations: ImmutableList) : Dialog } } 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 facef74346..ae1b765b6b 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 @@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.persistentListOf @@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider trackUserPosition = true, hasLocationPermission = true, ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.None, + trackUserPosition = true, + hasLocationPermission = true, + canShareLiveLocation = true, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer, + trackUserPosition = true, + hasLocationPermission = true, + canShareLiveLocation = true, + ), aShareLocationState( dialogState = ShareLocationState.Dialog.LiveLocationDurations( persistentListOf( @@ -73,6 +86,7 @@ fun aShareLocationState( hasLocationPermission: Boolean = false, canShareLiveLocation: Boolean = false, appName: String = APP_NAME, + startLiveLocationAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( @@ -82,6 +96,7 @@ fun aShareLocationState( hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, appName = appName, + startLiveLocationAction = startLiveLocationAction, eventSink = eventSink ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 1e163f417d..e20ee3a7a5 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -44,11 +43,16 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.rememberUserLocationState -import io.element.android.libraries.androidutils.system.toast +import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ListDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.RadioButtonListItem @@ -74,7 +78,6 @@ fun ShareLocationView( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current when (val dialogState = state.dialogState) { ShareLocationState.Dialog.None -> Unit is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog( @@ -85,12 +88,17 @@ fun ShareLocationView( onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, ) + ShareLocationState.Dialog.LiveLocationDisclaimer -> ConfirmationDialog( + content = stringResource(R.string.screen_share_location_live_location_disclaimer_title), + submitText = stringResource(CommonStrings.action_accept), + cancelText = stringResource(CommonStrings.action_decline), + onSubmitClick = { state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) }, + onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, + ) is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog( durations = dialogState.durations, onSelectDuration = { duration -> - state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) - context.toast("Not implemented yet!") - navigateUp() + state.eventSink(StartLiveLocationShare(duration)) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, ) @@ -160,10 +168,46 @@ fun ShareLocationView( .align(Alignment.TopEnd) .padding(all = 16.dp), ) + StartLiveLocationActionView(state.startLiveLocationAction, navigateUp) } ) } +@Composable +private fun StartLiveLocationActionView( + action: AsyncAction, + onActionSuccess: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + val asyncIndicatorState = rememberAsyncIndicatorState() + AsyncIndicatorHost(state = asyncIndicatorState) + + when (action) { + is AsyncAction.Loading -> { + LaunchedEffect(action) { + asyncIndicatorState.enqueue { + AsyncIndicator.Loading(text = stringResource(CommonStrings.common_waiting_live_location)) + } + } + } + is AsyncAction.Failure -> { + LaunchedEffect(action) { + asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) { + AsyncIndicator.Failure( + text = stringResource(CommonStrings.common_something_went_wrong), + ) + } + } + } + is AsyncAction.Success -> { + LaunchedEffect(action) { onActionSuccess() } + } + else -> Unit + } + } +} + @Composable private fun BottomSheetContent( cameraState: CameraState, @@ -202,7 +246,7 @@ private fun BottomSheetContent( } if (state.canShareLiveLocation) { ShareLiveLocationItem { - state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + state.eventSink(ShareLocationEvent.InitiateLiveLocationShare) } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt index 6a3e3521e0..34132dccf3 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt @@ -17,4 +17,5 @@ sealed interface ShowLocationEvent { data object RequestPermissions : ShowLocationEvent data object OpenAppSettings : ShowLocationEvent data object OpenLocationSettings : ShowLocationEvent + data object StopLocationSharing : ShowLocationEvent } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 43d3aa6d00..207b4b01dd 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -15,14 +15,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.SendLiveLocationPermissions import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.checkLocationConstraints import io.element.android.features.location.impl.common.permissions.PermissionsEvents @@ -45,6 +48,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch @AssistedInject class ShowLocationPresenter( @@ -55,6 +59,7 @@ class ShowLocationPresenter( private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, private val joinedRoom: JoinedRoom, + private val liveLocationShareManager: ActiveLiveLocationShareManager, ) : Presenter { @AssistedFactory fun interface Factory { @@ -65,6 +70,7 @@ class ShowLocationPresenter( @Composable override fun present(): ShowLocationState { + val coroutineScope = rememberCoroutineScope() val permissionsState: PermissionsState = permissionsPresenter.present() var isTrackMyLocation by remember { mutableStateOf(false) } val appName by remember { derivedStateOf { buildMeta.applicationName } } @@ -85,7 +91,7 @@ class ShowLocationPresenter( } is ShowLocationEvent.TrackMyLocation -> { if (event.enabled) { - val locationConstraints = checkLocationConstraints(permissionsState, locationActions) + val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success dialogState = locationConstraints.toDialogState() } else { @@ -102,6 +108,9 @@ class ShowLocationPresenter( dialogState = LocationConstraintsDialogState.None } ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + ShowLocationEvent.StopLocationSharing -> coroutineScope.launch { + liveLocationShareManager.stopShare(joinedRoom.roomId) + } } } @@ -127,6 +136,7 @@ class ShowLocationPresenter( location = mode.location, isLive = false, assetType = mode.assetType, + isOwnUser = mode.senderId == joinedRoom.sessionId ) ) } @@ -163,6 +173,7 @@ class ShowLocationPresenter( location = location, isLive = true, assetType = lastLocation.assetType, + isOwnUser = share.userId == joinedRoom.sessionId ) } .toImmutableList() diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index b6a60f35db..720697cbf7 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -38,7 +38,10 @@ data class LocationShareItem( val location: Location, val isLive: Boolean, val assetType: AssetType?, -) + val isOwnUser: Boolean +) { + val canStopSharing = isLive && isOwnUser +} fun LocationShareItem.toMarkerData(): LocationMarkerData { val pinVariant = if (assetType == AssetType.PIN) { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 2bbf4ae34e..1c7b9a3160 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -81,6 +81,7 @@ fun aLocationShareItem( assetType: AssetType? = null, formattedTimestamp: String = "Shared 1 min ago", location: Location = Location(1.23, 2.34, 4f), + isOwnUser: Boolean = false, ) = LocationShareItem( userId = userId, displayName = displayName, @@ -89,4 +90,5 @@ fun aLocationShareItem( location = location, isLive = isLive, assetType = assetType, + isOwnUser = isOwnUser, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 7ac5946723..6766fa6424 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -147,6 +147,7 @@ fun ShowLocationView( LocationShareRow( item = locationShare, onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, + onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) }, modifier = Modifier.clickable { state.eventSink(ShowLocationEvent.TrackMyLocation(false)) val position = CameraPosition( diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt index c8e1f21a48..debe95b464 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt @@ -21,7 +21,7 @@ class LocationConstraintsCheckTest { ) val locationActions = FakeLocationActions(isLocationEnabled = true) - val result = checkLocationConstraints(permissionsState, locationActions) + val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) assertThat(result).isEqualTo(LocationConstraintsCheck.Success) } @@ -33,7 +33,7 @@ class LocationConstraintsCheckTest { ) val locationActions = FakeLocationActions(isLocationEnabled = true) - val result = checkLocationConstraints(permissionsState, locationActions) + val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) assertThat(result).isEqualTo(LocationConstraintsCheck.Success) } @@ -45,7 +45,7 @@ class LocationConstraintsCheckTest { ) val locationActions = FakeLocationActions(isLocationEnabled = false) - val result = checkLocationConstraints(permissionsState, locationActions) + val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled) } @@ -58,7 +58,7 @@ class LocationConstraintsCheckTest { ) val locationActions = FakeLocationActions(isLocationEnabled = true) - val result = checkLocationConstraints(permissionsState, locationActions) + val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale) } @@ -71,8 +71,20 @@ class LocationConstraintsCheckTest { ) val locationActions = FakeLocationActions(isLocationEnabled = true) - val result = checkLocationConstraints(permissionsState, locationActions) + val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied) } + + @Test + fun `checkLocationConstraints returns NotEnoughPowerLevel when send permissions are not granted`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.DEFAULT) + + assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel) + } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt new file mode 100644 index 0000000000..85f0e1c33f --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt @@ -0,0 +1,488 @@ +/* + * Copyright (c) 2026 Element Creations 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.live + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator +import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultActiveLiveLocationShareManagerTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `starting the first share starts the coordinator service after the beacon echo and adds an active share`() = runTest { + val startServiceRecorder = lambdaRecorder { } + val stopServiceRecorder = lambdaRecorder { } + val coordinator = createCoordinator( + startService = startServiceRecorder, + stopService = stopServiceRecorder + ) + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val room = FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ) + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdates, + ).apply { givenGetRoomResult(A_ROOM_ID, room) }, + coordinator = coordinator, + clock = FakeSystemClock(epochMillisResult = 123L), + ) + advanceUntilIdle() + + val result = async { manager.startShare(A_ROOM_ID, 60.minutes) } + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + + assertThat(result.await().isSuccess).isTrue() + assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID) + assert(startServiceRecorder).isCalledOnce() + assert(stopServiceRecorder).isNeverCalled() + } + + @Test + fun `stopping the last share stops the coordinator service`() = runTest { + val startServiceRecorder = lambdaRecorder { } + val stopServiceRecorder = lambdaRecorder { } + val coordinator = createCoordinator( + startService = startServiceRecorder, + stopService = stopServiceRecorder + ) + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val room = FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ) + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdates, + ).apply { givenGetRoomResult(A_ROOM_ID, room) }, + coordinator = coordinator, + ) + advanceUntilIdle() + + val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) } + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + assertThat(startResult.await().isSuccess).isTrue() + + val result = manager.stopShare(A_ROOM_ID) + + assertThat(result.isSuccess).isTrue() + assertThat(manager.sharingRoomIds.value).isEmpty() + assert(startServiceRecorder).isCalledOnce() + assert(stopServiceRecorder).isCalledOnce() + } + + @Test + fun `two managers with the same room id keep isolated state per session`() = runTest { + val coordinator = createCoordinator() + val beaconInfoUpdatesOne = MutableSharedFlow(replay = 1) + val beaconInfoUpdatesTwo = MutableSharedFlow(replay = 1) + val managerOne = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdatesOne, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ), + ) + }, + coordinator = coordinator, + ) + val managerTwo = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID_2, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdatesTwo, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ), + ) + }, + coordinator = coordinator, + ) + advanceUntilIdle() + + val startResult = async { managerOne.startShare(A_ROOM_ID, 15.minutes) } + beaconInfoUpdatesOne.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + assertThat(startResult.await().isSuccess).isTrue() + + assertThat(managerOne.sharingRoomIds.value).containsExactly(A_ROOM_ID) + assertThat(managerTwo.sharingRoomIds.value).isEmpty() + } + + @Test + fun `start share persists room expiry after beacon echo`() = runTest { + val liveLocationStore = createLiveLocationStore() + val coordinator = createCoordinator() + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdates, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ), + ) + }, + coordinator = coordinator, + liveLocationStore = liveLocationStore, + clock = FakeSystemClock(epochMillisResult = 123L), + ) + advanceUntilIdle() + + val result = async { manager.startShare(A_ROOM_ID, 15.minutes) } + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + + assertThat(result.await().isSuccess).isTrue() + assertThat(liveLocationStore.getLiveLocationExpiries()).containsKey(A_ROOM_ID) + } + + @Test + fun `stop share removes persisted expiry`() = runTest { + val liveLocationStore = createLiveLocationStore() + val coordinator = createCoordinator() + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdates, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ), + ) + }, + coordinator = coordinator, + liveLocationStore = liveLocationStore, + ) + advanceUntilIdle() + + val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) } + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + assertThat(startResult.await().isSuccess).isTrue() + + manager.stopShare(A_ROOM_ID) + + assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID) + } + + @Test + fun `setup restores unexpired stored share and registers coordinator`() = runTest { + val startServiceRecorder = lambdaRecorder { } + val stopServiceRecorder = lambdaRecorder { } + val liveLocationStore = createLiveLocationStore().apply { + setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L)) + } + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ).apply { + givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom()) + }, + coordinator = createCoordinator( + startService = startServiceRecorder, + stopService = stopServiceRecorder, + ), + liveLocationStore = liveLocationStore, + clock = FakeSystemClock(epochMillisResult = 1_000L), + ) + + assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID) + assert(startServiceRecorder).isCalledOnce() + assert(stopServiceRecorder).isNeverCalled() + } + + @Test + fun `setup remotely stops expired stored share and removes it from store`() = runTest { + val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) } + val liveLocationStore = createLiveLocationStore().apply { + setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)) + } + createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult), + ) + }, + coordinator = createCoordinator(), + liveLocationStore = liveLocationStore, + clock = FakeSystemClock(epochMillisResult = 5_000L), + ) + advanceUntilIdle() + assert(stopLiveLocationShareResult).isCalledOnce() + assertThat(liveLocationStore.getLiveLocationExpiries()).isEmpty() + } + + @Test + fun `stop share closes loaded room and removes persisted expiry when room is not tracked`() = runTest { + val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult) + val liveLocationStore = createInMemoryLiveLocationStore() + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ).apply { + givenGetRoomResult(A_ROOM_ID, room) + }, + coordinator = createCoordinator(), + liveLocationStore = liveLocationStore, + ) + liveLocationStore.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L)) + + val result = manager.stopShare(A_ROOM_ID) + + assertThat(result.isSuccess).isTrue() + assert(stopLiveLocationShareResult).isCalledOnce() + assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID) + room.baseRoom.assertDestroyed() + } + + @Test + fun `share is automatically stopped when timeout elapses`() = runTest { + val liveLocationStore = createInMemoryLiveLocationStore() + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) } + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdates, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = stopLiveLocationShareResult + ), + ) + }, + coordinator = createCoordinator(), + liveLocationStore = liveLocationStore, + clock = FakeSystemClock(epochMillisResult = 123L), + ) + advanceUntilIdle() + + val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) } + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + assertThat(startResult.await().isSuccess).isTrue() + + manager.sharingRoomIds.test { + assertThat(awaitItem()).containsExactly(A_ROOM_ID) + assertThat(awaitItem()).isEmpty() + advanceUntilIdle() + assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID) + assert(stopLiveLocationShareResult).isCalledExactly(2) + } + } + + @Test + fun `restored share is automatically stopped when remaining timeout elapses`() = runTest { + val liveLocationStore = createInMemoryLiveLocationStore().apply { + setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(6_000L)) + } + val stopLiveLocationShareLambda = lambdaRecorder> { Result.success(Unit) } + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + stopLiveLocationShareResult = stopLiveLocationShareLambda + ), + ) + }, + coordinator = createCoordinator(), + liveLocationStore = liveLocationStore, + clock = FakeSystemClock(epochMillisResult = 1_000L), + ) + + manager.sharingRoomIds.test { + assertThat(awaitItem()).containsExactly(A_ROOM_ID) + assertThat(awaitItem()).isEmpty() + advanceUntilIdle() + assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID) + assert(stopLiveLocationShareLambda).isCalledOnce() + } + } + + @Test + fun `session deleted clears local state`() = runTest { + val startServiceRecorder = lambdaRecorder { } + val stopServiceRecorder = lambdaRecorder { } + val liveLocationStore = createInMemoryLiveLocationStore() + val sessionObserver = FakeSessionObserver() + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val manager = createManager( + client = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + ownBeaconInfoUpdates = beaconInfoUpdates, + ).apply { + givenGetRoomResult( + A_ROOM_ID, + FakeJoinedRoom( + startLiveLocationShareResult = { Result.success(AN_EVENT_ID) }, + stopLiveLocationShareResult = { Result.success(Unit) }, + ), + ) + }, + coordinator = createCoordinator( + startService = startServiceRecorder, + stopService = stopServiceRecorder, + ), + liveLocationStore = liveLocationStore, + sessionObserver = sessionObserver, + ) + advanceUntilIdle() + + val firstStart = async { manager.startShare(A_ROOM_ID, 15.minutes) } + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + assertThat(firstStart.await().isSuccess).isTrue() + + sessionObserver.onSessionDeleted(A_SESSION_ID.value) + advanceUntilIdle() + + assertThat(manager.sharingRoomIds.value).isEmpty() + assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID) + assert(startServiceRecorder).isCalledOnce() + assert(stopServiceRecorder).isCalledOnce() + + val secondStart = async { manager.startShare(A_ROOM_ID, 15.minutes) } + advanceUntilIdle() + assertThat(secondStart.isCompleted).isFalse() + + beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true)) + assertThat(secondStart.await().isSuccess).isTrue() + } + + private suspend fun createManager( + client: FakeMatrixClient = FakeMatrixClient(sessionId = A_SESSION_ID), + coordinator: LiveLocationSharingCoordinator = createCoordinator(), + liveLocationStore: LiveLocationStore = createLiveLocationStore(), + clock: SystemClock = FakeSystemClock(), + sessionObserver: SessionObserver = FakeSessionObserver(), + ): DefaultActiveLiveLocationShareManager { + return DefaultActiveLiveLocationShareManager( + matrixClient = client, + coordinator = coordinator, + liveLocationStore = liveLocationStore, + clock = clock, + sessionObserver = sessionObserver, + ).apply { + setup() + } + } + + private fun createCoordinator( + startService: () -> Unit = {}, + stopService: () -> Unit = {}, + nowMillis: () -> Long = { 0L }, + ): LiveLocationSharingCoordinator { + return LiveLocationSharingCoordinator( + startService = startService, + stopService = stopService, + nowMillis = nowMillis, + ) + } + + private fun createLiveLocationStore( + sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID, + preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + ): LiveLocationStore { + return LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = sessionId, + ) + } + + private fun createInMemoryLiveLocationStore( + sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID, + ): LiveLocationStore { + val preferenceDataStoreFactory = object : PreferenceDataStoreFactory { + override fun create(name: String): DataStore { + var preferences: Preferences = emptyPreferences() + return object : DataStore { + override val data: Flow + get() = flowOf(preferences) + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + preferences = transform(preferences) + return preferences + } + } + } + } + return createLiveLocationStore( + sessionId = sessionId, + preferenceDataStoreFactory = preferenceDataStoreFactory, + ) + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt new file mode 100644 index 0000000000..f74322b4c5 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Element Creations 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.live + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.live.service.LiveLocationReceiver +import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LiveLocationSharingCoordinatorTest { + @Test + fun `first registration starts the service and last unregister stops it`() = runTest { + var startCount = 0 + var stopCount = 0 + val coordinator = LiveLocationSharingCoordinator( + startService = { startCount++ }, + stopService = { stopCount++ }, + nowMillis = { 0L }, + ) + + coordinator.register(A_SESSION_ID, LiveLocationReceiver { }) + coordinator.unregister(A_SESSION_ID) + + assertThat(startCount).isEqualTo(1) + assertThat(stopCount).isEqualTo(1) + } + + @Test + fun `dispatch isolates receiver failures and still reaches later receivers`() = runTest { + val delivered = mutableListOf() + val coordinator = LiveLocationSharingCoordinator( + startService = { }, + stopService = { }, + nowMillis = { 4_000L }, + ) + + coordinator.register(A_SESSION_ID) { error("boom") } + coordinator.register(A_SESSION_ID_2) { location -> delivered += location } + coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f)) + + assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f)) + } + + @Test + fun `dispatch delivers first location immediately`() = runTest { + var nowMillis = 4_000L + val delivered = mutableListOf() + val coordinator = LiveLocationSharingCoordinator( + startService = { }, + stopService = { }, + nowMillis = { nowMillis }, + ) + + coordinator.register(A_SESSION_ID) { location -> delivered += location } + + val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f) + + coordinator.dispatch(firstLocation) + + assertThat(delivered).containsExactly(firstLocation) + } + + @Test + fun `dispatch drops updates inside the throttle window`() = runTest { + var nowMillis = 4_000L + val delivered = mutableListOf() + val coordinator = LiveLocationSharingCoordinator( + startService = { }, + stopService = { }, + nowMillis = { nowMillis }, + ) + + coordinator.register(A_SESSION_ID) { location -> delivered += location } + + val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f) + val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f) + + coordinator.dispatch(firstLocation) + nowMillis += 500 + coordinator.dispatch(secondLocation) + + assertThat(delivered).containsExactly(firstLocation) + } + + @Test + fun `dispatch delivers next update after the throttle window elapses`() = runTest { + var nowMillis = 4_000L + val delivered = mutableListOf() + val coordinator = LiveLocationSharingCoordinator( + startService = { }, + stopService = { }, + nowMillis = { nowMillis }, + ) + + coordinator.register(A_SESSION_ID) { location -> delivered += location } + + val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f) + val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f) + + coordinator.dispatch(firstLocation) + nowMillis += 3_000 + coordinator.dispatch(secondLocation) + + assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder() + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index edd000e02c..9b1a14fb51 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -13,6 +13,8 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat 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.live.LiveLocationStore +import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.featureflag.test.FakeFeatureFlagService @@ -20,8 +22,10 @@ import io.element.android.libraries.matrix.api.timeline.Timeline 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.preferences.test.FakePreferenceDataStoreFactory import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -30,16 +34,17 @@ class DefaultShareLocationEntryPointTest { val instantTaskExecutorRule = InstantTaskExecutorRule() @Test - fun `test node builder`() { + fun `test node builder`() = runTest { val entryPoint = DefaultShareLocationEntryPoint() val parentNode = TestParentNode.create { buildContext, plugins -> + val room = FakeJoinedRoom() ShareLocationNode( buildContext = buildContext, plugins = plugins, presenterFactory = { timelineMode: Timeline.Mode -> ShareLocationPresenter( permissionsPresenterFactory = { FakePermissionsPresenter() }, - room = FakeJoinedRoom(), + room = room, timelineMode = timelineMode, analyticsService = FakeAnalyticsService(), messageComposerContext = FakeMessageComposerContext(), @@ -48,6 +53,11 @@ class DefaultShareLocationEntryPointTest { featureFlagService = FakeFeatureFlagService(), client = FakeMatrixClient(), durationFormatter = FakeDurationFormatter(), + liveLocationShareManager = FakeActiveLiveLocationShareManager(), + liveLocationStore = LiveLocationStore( + preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + sessionId = room.sessionId, + ), ) }, analyticsService = FakeAnalyticsService(), 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 46bc4a55df..855deba694 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 @@ -10,6 +10,9 @@ package io.element.android.features.location.impl.share +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test @@ -22,28 +25,46 @@ 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.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.features.location.impl.live.LiveLocationStore +import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter 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.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType 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.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_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.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours class ShareLocationPresenterTest { @get:Rule @@ -59,9 +80,11 @@ class ShareLocationPresenterTest { private val durationFormatter = FakeDurationFormatter() - private fun createShareLocationPresenter( + private fun TestScope.createShareLocationPresenter( joinedRoom: JoinedRoom = FakeJoinedRoom(), locationActions: FakeLocationActions = fakeLocationActions, + liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), + liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -73,6 +96,8 @@ class ShareLocationPresenterTest { featureFlagService = fakeFeatureFlagService, client = fakeMatrixClient, durationFormatter = durationFormatter, + liveLocationShareManager = liveLocationShareManager, + liveLocationStore = liveLocationStore, ) @Test @@ -296,7 +321,15 @@ class ShareLocationPresenterTest { @Test fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest { - val shareLocationPresenter = createShareLocationPresenter() + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = grantedSendLiveLocationPermissions() + ) + ) + val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply { + setAcceptedLiveLocationDisclaimer().getOrThrow() + } + val shareLocationPresenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore) fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, @@ -307,7 +340,7 @@ class ShareLocationPresenterTest { shareLocationPresenter.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare) val durationDialogState = awaitItem() assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java) @@ -315,9 +348,155 @@ class ShareLocationPresenterTest { } } + @Test + fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest { + val presenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + presenter.test { + skipItems(1) + val state = awaitItem() + + state.eventSink(ShareLocationEvent.InitiateLiveLocationShare) + val dialogState = awaitItem() + + assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer) + } + } + + @Test + fun `AcceptLiveLocationDisclaimer persists acceptance and shows durations`() = runTest { + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = grantedSendLiveLocationPermissions() + ) + ) + val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId) + val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + presenter.test { + skipItems(1) + val state = awaitItem() + state.eventSink(ShareLocationEvent.InitiateLiveLocationShare) + awaitItem() + + state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) + val durationState = awaitItem() + + assertThat(locationStore.hasAcceptedLiveLocationDisclaimer()).isTrue() + assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java) + } + } + + @Test + fun `AcceptLiveLocationDisclaimer keeps disclaimer gate active when persistence fails`() = runTest { + val joinedRoom = FakeJoinedRoom() + val presenter = createShareLocationPresenter( + joinedRoom = joinedRoom, + liveLocationStore = createFailingLiveLocationStore(sessionId = joinedRoom.sessionId), + ) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + presenter.test { + skipItems(1) + val state = awaitItem() + state.eventSink(ShareLocationEvent.InitiateLiveLocationShare) + val disclaimerState = awaitItem() + + disclaimerState.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) + advanceUntilIdle() + + expectNoEvents() + } + } + + @Test + fun `ShowLiveLocationDurationPicker bypasses disclaimer when already accepted`() = runTest { + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = grantedSendLiveLocationPermissions() + ) + ) + val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply { + setAcceptedLiveLocationDisclaimer().getOrThrow() + } + val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + presenter.test { + skipItems(1) + val state = awaitItem() + + state.eventSink(ShareLocationEvent.InitiateLiveLocationShare) + val durationState = awaitItem() + + assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java) + } + } + + @Test + fun `ShowLiveLocationDurationPicker uses the active session disclaimer state`() = runTest { + val joinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = SessionId("@alice:server"))) + createLiveLocationStore(sessionId = SessionId("@bob:server")) + .setAcceptedLiveLocationDisclaimer() + .getOrThrow() + val presenter = createShareLocationPresenter( + joinedRoom = joinedRoom, + liveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), + ) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + presenter.test { + skipItems(1) + val state = awaitItem() + + state.eventSink(ShareLocationEvent.InitiateLiveLocationShare) + val dialogState = awaitItem() + + assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer) + } + } + @Test fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest { - val shareLocationPresenter = createShareLocationPresenter() + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = grantedSendLiveLocationPermissions() + ) + ) + val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply { + setAcceptedLiveLocationDisclaimer().getOrThrow() + } + val shareLocationPresenter = createShareLocationPresenter( + joinedRoom = joinedRoom, + liveLocationStore = locationStore, + ) fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -332,7 +511,7 @@ class ShareLocationPresenterTest { initialState.eventSink(ShareLocationEvent.DismissDialog) val dismissedState = awaitItem() - dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare) val constraintDialogState = awaitItem() assertThat(constraintDialogState.dialogState).isEqualTo( @@ -447,4 +626,62 @@ class ShareLocationPresenterTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `StartLiveLocationShare event calls manager startShare`() = runTest { + val startShareLambda = lambdaRecorder { _: RoomId, _: Duration -> Result.success(Unit) } + val manager = FakeActiveLiveLocationShareManager( + startShareLambda = startShareLambda, + ) + val shareLocationPresenter = createShareLocationPresenter(liveLocationShareManager = manager) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val state = awaitItem() + state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours)) + advanceUntilIdle() + assert(startShareLambda).isCalledOnce().with( + value(A_ROOM_ID), + value(1.hours) + ) + cancelAndIgnoreRemainingEvents() + } + } } + +private fun createLiveLocationStore( + sessionId: SessionId = A_SESSION_ID, + preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(), +): LiveLocationStore { + return LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = sessionId, + ) +} + +private fun createFailingLiveLocationStore(sessionId: SessionId = A_SESSION_ID): LiveLocationStore { + val failingPreferenceDataStoreFactory = object : PreferenceDataStoreFactory { + override fun create(name: String): DataStore = object : DataStore { + override val data: Flow = flowOf(emptyPreferences()) + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + error("Failed to update preferences") + } + } + } + return createLiveLocationStore( + sessionId = sessionId, + preferenceDataStoreFactory = failingPreferenceDataStoreFactory, + ) +} + +private fun grantedSendLiveLocationPermissions(): FakeRoomPermissions = FakeRoomPermissions( + canSendState = { it is StateEventType.BeaconInfo }, + canSendMessage = { it is MessageEventType.Beacon } +) 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 index 63c19ba913..370ccac8ab 100644 --- 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 @@ -143,6 +143,38 @@ class ShareLocationViewTest { clickOn(CommonStrings.action_cancel) eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) } + + @Test + fun `when disclaimer is displayed user can accept`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer, + eventSink = eventsRecorder, + canShareLiveLocation = true, + ), + navigateUp = EnsureNeverCalled(), + ) + + clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(ShareLocationEvent.AcceptLiveLocationDisclaimer) + } + + @Test + fun `when disclaimer is displayed user can decline`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer, + eventSink = eventsRecorder, + canShareLiveLocation = true, + ), + navigateUp = EnsureNeverCalled(), + ) + + clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } } private fun AndroidComposeUiTest.setShareLocationView( 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 91df447e2a..985dcc1f9c 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 @@ -16,6 +16,7 @@ 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.features.location.test.FakeActiveLiveLocationShareManager 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 @@ -34,6 +35,7 @@ class DefaultShowLocationEntryPointTest { fun `test node builder`() { val entryPoint = DefaultShowLocationEntryPoint() val parentNode = TestParentNode.create { buildContext, plugins -> + val joinedRoom = FakeJoinedRoom() ShowLocationNode( buildContext = buildContext, plugins = plugins, @@ -45,7 +47,8 @@ class DefaultShowLocationEntryPointTest { buildMeta = aBuildMeta(), dateFormatter = FakeDateFormatter(), stringProvider = FakeStringProvider(), - joinedRoom = FakeJoinedRoom(), + joinedRoom = joinedRoom, + liveLocationShareManager = FakeActiveLiveLocationShareManager(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt index 4042cb4c0c..0b8e04abf8 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt @@ -9,7 +9,7 @@ package io.element.android.features.location.impl.show import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare import org.junit.Test class LiveLocationShareComparatorTest { @@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest { assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder() } } - -private fun aLiveLocationShare( - userId: UserId, - startTimestamp: Long, -): LiveLocationShare { - return LiveLocationShare( - userId = userId, - lastLocation = null, - startTimestamp = startTimestamp, - endTimestamp = startTimestamp + 1_000L, - ) -} 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 f38e8dae60..c1f9f6487e 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 @@ -20,14 +20,15 @@ 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.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId 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.LastLocation import io.element.android.libraries.matrix.api.room.location.LiveLocationShare 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.location.aLiveLocationShare import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -60,6 +61,7 @@ class ShowLocationPresenterTest { ), locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), + liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -68,6 +70,7 @@ class ShowLocationPresenterTest { dateFormatter = fakeDateFormatter, stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, + liveLocationShareManager = liveLocationShareManager, ) @Test @@ -205,7 +208,7 @@ class ShowLocationPresenterTest { ) ) val presenter = createShowLocationPresenter() - presenter.test { + presenter.test { // Skip initial state val initialState = awaitItem() @@ -464,23 +467,3 @@ class ShowLocationPresenterTest { } } } - -private fun aLiveLocationShare( - userId: UserId, - geoUri: String = "geo:48.8584,2.2945", - timestamp: Long = 0L, - startTimestamp: Long = 0L, - endTimestamp: Long = Long.MAX_VALUE, - assetType: AssetType = AssetType.SENDER, -): LiveLocationShare { - return LiveLocationShare( - userId = userId, - lastLocation = LastLocation( - geoUri = geoUri, - timestamp = timestamp, - assetType = assetType, - ), - startTimestamp = startTimestamp, - endTimestamp = endTimestamp, - ) -} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt new file mode 100644 index 0000000000..c42469e705 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Element Creations 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.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.impl.live.LiveLocationStore +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Instant + +class LiveLocationStoreTest { + private val preferenceDataStoreFactory = FakePreferenceDataStoreFactory() + + @Test + fun `disclaimer defaults to false`() = runTest { + val store = LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = A_SESSION_ID, + ) + + assertThat(store.hasAcceptedLiveLocationDisclaimer()).isFalse() + } + + @Test + fun `disclaimer acceptance is isolated per session`() = runTest { + val firstStore = LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = A_SESSION_ID, + ) + val secondStore = LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = SessionId("@other:server"), + ) + + firstStore.setAcceptedLiveLocationDisclaimer().getOrThrow() + + assertThat(firstStore.hasAcceptedLiveLocationDisclaimer()).isTrue() + assertThat(secondStore.hasAcceptedLiveLocationDisclaimer()).isFalse() + } + + @Test + fun `can persist and read expiry per room`() = runTest { + val store = LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = A_SESSION_ID, + ) + + store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow() + + assertThat(store.getLiveLocationExpiries()) + .containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)) + } + + @Test + fun `removing one expiry leaves others untouched`() = runTest { + val otherRoomId = RoomId("!other:server") + val store = LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = A_SESSION_ID, + ) + + store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow() + store.setLiveLocationExpiry(otherRoomId, Instant.fromEpochMilliseconds(2_000L)).getOrThrow() + store.removeLiveLocationExpiry(A_ROOM_ID).getOrThrow() + + assertThat(store.getLiveLocationExpiries()) + .containsExactly(otherRoomId, Instant.fromEpochMilliseconds(2_000L)) + } + + @Test + fun `setting expiry twice replaces the existing room value`() = runTest { + val store = LiveLocationStore( + preferenceDataStoreFactory = preferenceDataStoreFactory, + sessionId = A_SESSION_ID, + ) + + store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow() + store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L)).getOrThrow() + + assertThat(store.getLiveLocationExpiries()) + .containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L)) + } + + @Test + fun `malformed expiry payload returns empty map`() = runTest { + val store = LiveLocationStore( + preferenceDataStoreFactory = createMalformedExpiryPreferenceDataStoreFactory(), + sessionId = A_SESSION_ID, + ) + + assertThat(store.getLiveLocationExpiries()).isEmpty() + } + + private fun createMalformedExpiryPreferenceDataStoreFactory(): PreferenceDataStoreFactory { + return object : PreferenceDataStoreFactory { + override fun create(name: String): DataStore { + var preferences: Preferences = mutablePreferencesOf( + stringPreferencesKey("live_location_expiries") to "not valid" + ) + return object : DataStore { + override val data: Flow + get() = flowOf(preferences) + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + preferences = transform(preferences) + return preferences + } + } + } + } + } +} diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.kt new file mode 100644 index 0000000000..255c181ac1 --- /dev/null +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Element Creations 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.test + +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlin.time.Duration + +class FakeActiveLiveLocationShareManager( + val setupLambda: () -> Unit = { lambdaError() }, + val startShareLambda: (roomId: RoomId, duration: Duration) -> Result = { _, _ -> lambdaError() }, + val stopShareLambda: (roomId: RoomId) -> Result = { _ -> lambdaError() }, +) : ActiveLiveLocationShareManager { + private val _sharingRoomIds = MutableStateFlow(emptySet()) + override val sharingRoomIds: StateFlow> = _sharingRoomIds + + override suspend fun setup() { + setupLambda() + } + + override suspend fun startShare(roomId: RoomId, duration: Duration): Result = simulateLongTask { + startShareLambda(roomId, duration).onSuccess { + _sharingRoomIds.update { + it + roomId + } + } + } + + override suspend fun stopShare(roomId: RoomId): Result = simulateLongTask { + stopShareLambda(roomId).onSuccess { + _sharingRoomIds.update { + it - roomId + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index bef8ca84d6..4d621e417f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -18,6 +18,8 @@ sealed interface MessagesEvent { data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent data class OnUserClicked(val user: MatrixUser) : MessagesEvent + data object StopLiveLocationShare : MessagesEvent + data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index ff46ce0041..d20dc4b38e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -278,6 +278,10 @@ class MessagesFlowNode( backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId)) } + override fun navigateToCurrentLiveLocation() { + backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId))) + } + override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) { val callData = CallData( sessionId = sessionId, @@ -513,6 +517,10 @@ class MessagesFlowNode( backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId)) } + override fun navigateToCurrentLiveLocation() { + backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId))) + } + override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) { val callData = CallData( sessionId = sessionId, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index e475f579c3..6113b68aab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -26,5 +26,6 @@ interface MessagesNavigator { fun navigateToMember(userId: UserId) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) fun navigateToDeveloperSettings() + fun navigateToCurrentLiveLocation() fun close() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 308cda506e..a9ce2f5ba1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -127,6 +127,7 @@ class MessagesNode( fun navigateToSendLocation() fun navigateToCreatePoll() fun navigateToEditPoll(eventId: EventId) + fun navigateToCurrentLiveLocation() fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) fun navigateToRoomDetails() @@ -239,6 +240,10 @@ class MessagesNode( callback.navigateToDeveloperSettings() } + override fun navigateToCurrentLiveLocation() { + callback.navigateToCurrentLiveLocation() + } + private fun displaySameRoomToast() { context.toast(CommonStrings.screen_room_permalink_same_room_android) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 5271906fff..1d6f1d3c1b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -27,6 +27,8 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.appconfig.MessageComposerConfig +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager +import io.element.android.features.location.api.live.isCurrentlySharing import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.impl.MessagesState.Threads import io.element.android.features.messages.impl.actionlist.ActionListState @@ -79,6 +81,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -126,6 +129,7 @@ class MessagesPresenter( private val featureFlagService: FeatureFlagService, private val addRecentEmoji: AddRecentEmoji, private val markAsFullyRead: MarkAsFullyRead, + private val liveLocationShareManager: ActiveLiveLocationShareManager, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : Presenter { @AssistedFactory @@ -172,6 +176,7 @@ class MessagesPresenter( } val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false) + val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState() val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> perms.userEventPermissions() @@ -260,6 +265,18 @@ class MessagesPresenter( is MessagesEvent.OnUserClicked -> { roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user)) } + MessagesEvent.StopLiveLocationShare -> { + localCoroutineScope.launch { + liveLocationShareManager.stopShare(room.roomId) + .onFailure { + Timber.e(it, "Failed to stop live location share for roomId=${room.roomId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + } + } + MessagesEvent.ShowLiveLocationShare -> { + navigator.navigateToCurrentLiveLocation() + } is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) { coroutineScope.launch { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { @@ -311,6 +328,7 @@ class MessagesPresenter( // TODO calculate this properly based on the thread list and the read state of each thread hasUnreadThreads = false, ), + showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread, eventSink = ::handleEvent, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 862f30832b..a16485c6f7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -58,6 +58,7 @@ data class MessagesState( val topBarSharedHistoryIcon: SharedHistoryIcon, val successorRoom: SuccessorRoom?, val threads: Threads, + val showLiveLocationShareBanner: Boolean, val eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 14c83db833..6389089e07 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -80,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider { currentPinnedMessageIndex = 0, ), ), + aMessagesState(isCurrentlySharingLiveLocationInRoom = true), aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)), aMessagesState( timelineState = aTimelineState( @@ -127,6 +128,7 @@ fun aMessagesState( hasThreads = false, hasUnreadThreads = false, ), + isCurrentlySharingLiveLocationInRoom: Boolean = false, eventSink: (MessagesEvent) -> Unit = {}, ) = MessagesState( roomId = RoomId("!id:domain"), @@ -156,6 +158,7 @@ fun aMessagesState( topBarSharedHistoryIcon = topBarSharedHistoryIcon, successorRoom = successorRoom, threads = threads, + showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5a0b14b820..3e6f14e805 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.location.api.LiveLocationSharingBanner import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListView @@ -205,15 +206,15 @@ fun MessagesView( val expandableState = rememberExpandableBottomSheetLayoutState() ExpandableBottomSheetLayout( modifier = modifier - .fillMaxSize() - .imePadding() - .systemBarsPadding() - .onSizeChanged { size -> - // Let the composer takes at max half of the available height. - // The value will be different if the soft keyboard is displayed - // or not. - maxComposerHeightPx = (size.height * 0.5f).toInt() - }, + .fillMaxSize() + .imePadding() + .systemBarsPadding() + .onSizeChanged { size -> + // Let the composer takes at max half of the available height. + // The value will be different if the soft keyboard is displayed + // or not. + maxComposerHeightPx = (size.height * 0.5f).toInt() + }, content = { Scaffold( contentWindowInsets = WindowInsets.statusBars, @@ -250,8 +251,8 @@ fun MessagesView( content = { padding -> Box( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) { MessagesViewContent( state = state, @@ -288,10 +289,10 @@ fun MessagesView( SuggestionsPickerView( modifier = Modifier - .shadow(10.dp) - .background(ElementTheme.colors.bgCanvasDefault) - .align(Alignment.BottomStart) - .heightIn(max = 230.dp), + .shadow(10.dp) + .background(ElementTheme.colors.bgCanvasDefault) + .align(Alignment.BottomStart) + .heightIn(max = 230.dp), roomId = state.roomId, roomName = state.roomName, roomAvatarData = state.roomAvatar, @@ -467,9 +468,9 @@ private fun MessagesViewContent( ) { Box( modifier = modifier - .fillMaxSize() - .navigationBarsPadding() - .imePadding(), + .fillMaxSize() + .navigationBarsPadding() + .imePadding(), ) { AttachmentsBottomSheet( state = state.composerState, @@ -520,25 +521,34 @@ private fun MessagesViewContent( ) if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { - AnimatedVisibility( - visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, - modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } }, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - fun focusOnPinnedEvent(eventId: EventId) { - state.timelineState.eventSink( - TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) + Column { + AnimatedVisibility( + visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, + modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } }, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + fun focusOnPinnedEvent(eventId: EventId) { + state.timelineState.eventSink( + TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) + ) + } + PinnedMessagesBannerView( + state = state.pinnedMessagesBannerState, + onClick = ::focusOnPinnedEvent, + onViewAllClick = onViewAllPinnedMessagesClick, + ) + } + if (state.showLiveLocationShareBanner) { + LiveLocationSharingBanner( + onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) }, + onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) } ) } - PinnedMessagesBannerView( - state = state.pinnedMessagesBannerState, - onClick = ::focusOnPinnedEvent, - onViewAllClick = onViewAllPinnedMessagesClick, - ) } - knockRequestsBannerView() } + + knockRequestsBannerView() } } } @@ -587,9 +597,9 @@ private fun MessagesViewComposerBottomSheetContents( private fun CantSendMessageBanner() { Row( modifier = Modifier - .fillMaxWidth() - .background(ElementTheme.colors.bgSubtleSecondary) - .padding(16.dp), + .fillMaxWidth() + .background(ElementTheme.colors.bgSubtleSecondary) + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt index a69c6d7612..56bac5be33 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent @@ -127,7 +128,7 @@ open class ActionListStateProvider : PreviewParameterProvider { anActionListState( target = ActionListState.Target.Success( event = aTimelineItemEvent( - content = aTimelineItemLocationContent(), + content = aTimelineItemLocationContent(mode = aStaticLocationMode()), timelineItemReactions = reactionsState ), sentTimeFull = "January 1, 1970 at 12:00 AM", @@ -140,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider { anActionListState( target = ActionListState.Target.Success( event = aTimelineItemEvent( - content = aTimelineItemLocationContent(), + content = aTimelineItemLocationContent(mode = aStaticLocationMode()), timelineItemReactions = reactionsState ), sentTimeFull = "January 1, 1970 at 12:00 AM", diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 0c58316b5e..63ce48e100 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -136,6 +136,7 @@ class ThreadedMessagesNode( fun navigateToSendLocation() fun navigateToCreatePoll() fun navigateToEditPoll(eventId: EventId) + fun navigateToCurrentLiveLocation() fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) fun navigateToDeveloperSettings() @@ -248,6 +249,11 @@ class ThreadedMessagesNode( callback.navigateToDeveloperSettings() } + override fun navigateToCurrentLiveLocation() { + // Shouldn't happen because LiveLocationSharingBanner is not shown in threads. + callback.navigateToCurrentLiveLocation() + } + override fun close() = navigateUp() @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index edd8d446dc..faa4aabe8e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent @@ -94,6 +95,7 @@ class TimelinePresenter( private val roomCallStatePresenter: Presenter, private val featureFlagService: FeatureFlagService, private val analyticsService: AnalyticsService, + private val liveLocationShareManager: ActiveLiveLocationShareManager, ) : Presenter { private val tag = "TimelinePresenter" @@ -200,7 +202,9 @@ class TimelinePresenter( is TimelineEvent.EditPoll -> { navigator.navigateToEditPoll(event.pollStartId) } - is TimelineEvent.StopLiveLocationShare -> Unit + is TimelineEvent.StopLiveLocationShare -> sessionCoroutineScope.launch { + liveLocationShareManager.stopShare(room.roomId) + } is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch { focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) delay(event.debounce) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 5785627564..936028cbd6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -783,7 +783,7 @@ private fun MessageEventBubbleContent( val content = content.ensureActiveLiveLocation() val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && content.mode.isActive && - content.mode.canStop + content.mode.isOwnUser if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay } is TimelineItemPollContent -> TimestampPosition.Below diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index f00b6b0b5b..4ab4ee84a6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -13,14 +13,12 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -77,13 +75,14 @@ private fun LiveLocationOverlay( Row( modifier = modifier .fillMaxWidth() - .background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)) - .padding(horizontal = 8.dp, vertical = 8.dp), + .background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)), verticalAlignment = Alignment.CenterVertically, ) { val iconShape = RoundedCornerShape(8.dp) Box( modifier = Modifier + // Ensure this Box uses same spacings than the Stop IconButton. + .minimumInteractiveComponentSize() .size(32.dp) .border( width = 1.dp, @@ -120,7 +119,6 @@ private fun LiveLocationOverlay( ) } } - Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = if (mode.isActive) { @@ -140,13 +138,16 @@ private fun LiveLocationOverlay( } } - if (mode.isActive && mode.canStop) { + if (mode.canStopSharing) { IconButton( onClick = onStopClick, colors = IconButtonDefaults.iconButtonColors( containerColor = ElementTheme.colors.bgCriticalPrimary, contentColor = ElementTheme.colors.iconOnSolidPrimary, - ) + ), + modifier = Modifier + .minimumInteractiveComponentSize() + .size(30.dp) ) { Icon( imageVector = CompoundIcons.Stop(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt index 7c36521fc0..0d51d9f5b8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt @@ -8,7 +8,9 @@ package io.element.android.features.messages.impl.timeline.di +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.aVoiceMessageState @@ -18,6 +20,12 @@ import io.element.android.libraries.voiceplayer.api.aVoiceMessageState */ fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories( mapOf( + Pair( + TimelineItemLocationContent::class, + TimelineItemPresenterFactory { content -> + Presenter { content.ensureActiveLiveLocation() } + }, + ), Pair( TimelineItemVoiceContent::class, TimelineItemPresenterFactory { Presenter { aVoiceMessageState() } }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index dff195e833..11f6668dfb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -127,6 +127,7 @@ class TimelineItemContentFactory( isActive = itemContent.isLive, endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt), endTimestamp = itemContent.endTimestamp, + isOwnUser = sessionId == sender ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 44dd2df38d..52e008e121 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -27,7 +27,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aTimelineItemLocationContent(), aTimelineItemLocationContent( - mode = TimelineItemLocationContent.Mode.Live( - isActive = true, - endsAt = "Ends at 12:34", - endTimestamp = 0L, - canStop = true, - lastKnownLocation = aLocation() - ), + mode = aStaticLocationMode() ), aTimelineItemLocationContent( - mode = TimelineItemLocationContent.Mode.Live( - isActive = true, - endsAt = "Ends at 12:34", - endTimestamp = 0L, - lastKnownLocation = aLocation() - ), + mode = aLiveLocationMode(isActive = true) ), aTimelineItemLocationContent( - mode = TimelineItemLocationContent.Mode.Live( - isActive = true, - endsAt = "Ends at 12:34", - endTimestamp = 0L, - lastKnownLocation = null - ), + mode = aLiveLocationMode(isActive = true, lastKnownLocation = null) ), aTimelineItemLocationContent( - mode = TimelineItemLocationContent.Mode.Live( - isActive = false, - endsAt = "", - endTimestamp = 0L, - lastKnownLocation = aLocation() - ), + mode = aLiveLocationMode(isActive = true, isOwnUser = false) + ), + aTimelineItemLocationContent( + mode = aLiveLocationMode(isActive = false) ), ) } +fun aLiveLocationMode( + isActive: Boolean, + isOwnUser: Boolean = true, + lastKnownLocation: Location? = aLocation(), + endsAt: String = "Ends at 12:34", + endTimestamp: Long = 0L, +): TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Live( + isActive = isActive, + endsAt = endsAt, + endTimestamp = endTimestamp, + isOwnUser = isOwnUser, + lastKnownLocation = lastKnownLocation +) + +fun aStaticLocationMode(location: Location = aLocation()) = TimelineItemLocationContent.Mode.Static(location) fun aTimelineItemLocationContent( senderId: UserId = UserId("@sender:matrix.org"), senderProfile: ProfileDetails = aProfileDetailsReady(), description: String? = null, - mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()), + mode: TimelineItemLocationContent.Mode, ) = TimelineItemLocationContent( senderId = senderId, senderProfile = senderProfile, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 44d82f1a7c..68f9a8d17a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -28,6 +28,7 @@ class FakeMessagesNavigator( private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() }, private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val closeLambda: () -> Unit = { lambdaError() }, + private val navigateToCurrentLiveLocationLambda: () -> Unit = { lambdaError() }, ) : MessagesNavigator { override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { onShowEventDebugInfoClickLambda(eventId, debugInfo) @@ -65,6 +66,10 @@ class FakeMessagesNavigator( navigateToDeveloperSettingsLambda() } + override fun navigateToCurrentLiveLocation() { + navigateToCurrentLiveLocationLambda() + } + override fun close() { closeLambda() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 50bacb005f..0c836e6cc0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -13,6 +13,7 @@ package io.element.android.features.messages.impl import androidx.lifecycle.Lifecycle import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState @@ -120,6 +121,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") class MessagesPresenterTest { @@ -140,6 +142,39 @@ class MessagesPresenterTest { assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.showReinvitePrompt).isFalse() + assertThat(initialState.showLiveLocationShareBanner).isFalse() + } + } + + @Test + fun `present - exposes live location sharing banner visibility for current room`() = runTest { + val liveLocationShareManager = FakeActiveLiveLocationShareManager( + startShareLambda = { _, _ -> Result.success(Unit) }, + ) + liveLocationShareManager.startShare(A_ROOM_ID, 60.seconds) + val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager) + + presenter.testWithLifecycleOwner { + val state = consumeItemsUntilTimeout().last() + assertThat(state.showLiveLocationShareBanner).isTrue() + } + } + + @Test + fun `present - stop live location share delegates to manager for current room`() = runTest { + val stopShareLambda = lambdaRecorder> { Result.success(Unit) } + val liveLocationShareManager = FakeActiveLiveLocationShareManager( + stopShareLambda = stopShareLambda + ) + val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager) + + presenter.testWithLifecycleOwner { + val state = consumeItemsUntilTimeout().last() + state.eventSink(MessagesEvent.StopLiveLocationShare) + advanceUntilIdle() + assert(stopShareLambda) + .isCalledOnce() + .with(value(A_ROOM_ID)) } } @@ -1347,6 +1382,7 @@ class MessagesPresenterTest { actionListEventSink: (ActionListEvent) -> Unit = {}, addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() }, markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(), + liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), ): MessagesPresenter { return MessagesPresenter( navigator = navigator, @@ -1376,6 +1412,7 @@ class MessagesPresenterTest { featureFlagService = featureFlagService, addRecentEmoji = addRecentEmoji, markAsFullyRead = markAsFullyRead, + liveLocationShareManager = liveLocationShareManager, sessionCoroutineScope = backgroundScope, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 70ef70325e..be44c64a5a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -643,6 +643,44 @@ class MessagesViewTest { assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message) assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action) } + + @Test + fun `live location banner is visible when current room is sharing`() = runAndroidComposeUiTest { + val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = true) + setMessagesView(state = state) + onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertExists() + } + + @Test + fun `live location banner is hidden when current room is not sharing`() = runAndroidComposeUiTest { + val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = false) + setMessagesView(state = state) + onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertDoesNotExist() + } + + @Test + fun `clicking stop on live location banner emits expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + isCurrentlySharingLiveLocationInRoom = true, + eventSink = eventsRecorder, + ) + setMessagesView(state = state) + clickOn(CommonStrings.action_stop) + eventsRecorder.assertSingle(MessagesEvent.StopLiveLocationShare) + } + + @Test + fun `clicking live location banner emit expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + isCurrentlySharingLiveLocationInRoom = true, + eventSink = eventsRecorder, + ) + setMessagesView(state = state) + clickOn(CommonStrings.screen_room_live_location_banner) + eventsRecorder.assertSingle(MessagesEvent.ShowLiveLocationShare) + } } private fun AndroidComposeUiTest.setMessagesView( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 194694714b..af37fb61ef 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.impl.FakeMessagesNavigator import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.fixtures.aMessageEvent @@ -1012,6 +1013,7 @@ class TimelinePresenterTest { sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1030,6 +1032,7 @@ class TimelinePresenterTest { roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, analyticsService = FakeAnalyticsService(), + liveLocationShareManager = liveLocationShareManager, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index b3fb68fe05..ef27c499af 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -20,4 +20,5 @@ sealed interface AdvancedSettingsEvents { data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents + data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt index e58706e9fe..96b1cddc94 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt @@ -10,12 +10,14 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -28,10 +30,12 @@ class AdvancedSettingsNode( @Composable override fun View(modifier: Modifier) { val state = presenter.present() + val context = LocalContext.current AdvancedSettingsView( state = state, modifier = modifier, - onBackClick = ::navigateUp + onBackClick = ::navigateUp, + onOpenAppSettingsClick = context::openAppSettingsPage ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index ae32bb01a5..3d1fb3b0c7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -25,8 +25,11 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @Inject @@ -53,6 +56,19 @@ class AdvancedSettingsPresenter( appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed) }.collectAsState(initial = Theme.System) + @OptIn(ExperimentalCoroutinesApi::class) + val liveLocationMinimumDistanceUpdate by produceState(null) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) + .flatMapLatest { isEnabled -> + if (isEnabled) { + appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() + } else { + emptyFlow() + } + } + .collect { value = it } + } + val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() val themeOption by remember { @@ -117,6 +133,9 @@ class AdvancedSettingsPresenter( } is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) + is AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch { + appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value) + } is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch { sessionPreferencesStore.setOptimizeImages(event.compress) } @@ -133,6 +152,7 @@ class AdvancedSettingsPresenter( theme = themeOption, availableThemeOptions = availableThemeOptions, mediaPreviewConfigState = mediaPreviewConfigState, + liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 0525130048..1deb972abc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -23,6 +23,7 @@ data class AdvancedSettingsState( val theme: ThemeOption, val availableThemeOptions: ImmutableList, val mediaPreviewConfigState: MediaPreviewConfigState, + val liveLocationMinimumDistanceUpdate: Int?, val eventSink: (AdvancedSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 87df614074..2df59e3ced 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -41,6 +41,7 @@ fun aAdvancedSettingsState( availableThemeOptions: ImmutableList = ThemeOption.entries.toImmutableList(), hideInviteAvatars: Boolean = false, timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, + liveLocationMinimumDistanceUpdate: Int? = 50, setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (AdvancedSettingsEvents) -> Unit = {}, @@ -56,5 +57,6 @@ fun aAdvancedSettingsState( setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, setHideInviteAvatarsAction = setHideInviteAvatarsAction ), + liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, eventSink = eventSink ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 230f5fa739..af28e3443f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -8,15 +8,24 @@ package io.element.android.features.preferences.impl.advanced +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import im.vector.app.features.analytics.plan.Interaction import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R @@ -33,10 +42,12 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.stringWithLink import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.ListSupportingText import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults +import io.element.android.libraries.designsystem.theme.components.Slider import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost @@ -47,11 +58,13 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlin.math.roundToInt @Composable fun AdvancedSettingsView( state: AdvancedSettingsState, onBackClick: () -> Unit, + onOpenAppSettingsClick: () -> Unit, modifier: Modifier = Modifier, ) { val analyticsService = LocalAnalyticsService.current @@ -190,6 +203,15 @@ fun AdvancedSettingsView( } ModerationAndSafety(state) + if (state.liveLocationMinimumDistanceUpdate != null) { + LiveLocationUpdatesSection( + value = state.liveLocationMinimumDistanceUpdate, + onValueSaved = { value -> + state.eventSink(AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate(value)) + }, + onOpenAppPermissionsClick = onOpenAppSettingsClick, + ) + } } } @@ -314,6 +336,78 @@ private fun ModerationAndSafety( } } +@Composable +private fun LiveLocationUpdatesSection( + value: Int, + onValueSaved: (Int) -> Unit, + onOpenAppPermissionsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceCategory( + modifier = modifier, + showTopDivider = true, + ) { + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_live_location_section_title), + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_live_location_section_description), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } + ) + var sliderValue by remember(value) { mutableIntStateOf(value) } + Column( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = pluralStringResource( + R.plurals.screen_advanced_settings_live_location_update_distance, + sliderValue, + sliderValue, + ), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + val valueRange = 1f..100f + val start = valueRange.start.toInt() + val end = valueRange.endInclusive.toInt() + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) + Slider( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + value = sliderValue.toFloat(), + onValueChange = { sliderValue = it.roundToInt() }, + onValueChangeFinish = { + onValueSaved(sliderValue) + }, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = ElementTheme.colors.iconAccentPrimary, + activeTrackColor = ElementTheme.colors.iconAccentPrimary, + inactiveTrackColor = ElementTheme.colors.bgBadgeAccent, + inactiveTickColor = ElementTheme.colors.iconAccentPrimary, + ) + ) + Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) + } + } + val footerText = stringWithLink( + textRes = R.string.screen_advanced_settings_live_location_section_footer, + url = "", + linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link, + onLinkClick = { onOpenAppPermissionsClick() }, + ) + ListSupportingText( + annotatedString = footerText, + contentPadding = ListSupportingTextDefaults.Padding.Default, + ) + } +} + @PreviewWithLargeHeight @Composable internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = @@ -334,7 +428,8 @@ internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettings private fun ContentToPreview(state: AdvancedSettingsState) { AdvancedSettingsView( state = state, - onBackClick = { } + onBackClick = { }, + onOpenAppSettingsClick = {} ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index fe121e4fe9..27d91bd8de 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore @@ -209,6 +210,72 @@ class AdvancedSettingsPresenterTest { } } + @Test + fun `present - live location minimum distance is null when feature is disabled`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 50, + ) + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.LiveLocationSharing, false) + } + val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isNull() + } + } + } + + @Test + fun `present - exposes live location minimum distance from app preferences`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 50, + ) + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.LiveLocationSharing, true) + } + val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50) + } + } + } + + @Test + fun `present - saving live location minimum distance updates app preferences`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 10, + ) + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.LiveLocationSharing, true) + } + val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) + eventSink(AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate(42)) + } + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42) + } + } + } + @Test fun `present - black theme option shown when feature flag enabled`() = runTest { val presenter = createAdvancedSettingsPresenter( @@ -338,7 +405,7 @@ class AdvancedSettingsPresenterTest { } private fun CoroutineScope.createAdvancedSettingsPresenter( - appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt index b6fe5c3d0b..4823a7c26e 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -250,6 +250,7 @@ private fun AndroidComposeUiTest.setAdvancedSettingsView( state: AdvancedSettingsState, analyticsService: AnalyticsService = FakeAnalyticsService(), onBackClick: () -> Unit = EnsureNeverCalled(), + onOpenAppSettings: () -> Unit = EnsureNeverCalled(), ) { setContent { CompositionLocalProvider( @@ -258,6 +259,7 @@ private fun AndroidComposeUiTest.setAdvancedSettingsView( AdvancedSettingsView( state = state, onBackClick = onBackClick, + onOpenAppSettingsClick = onOpenAppSettings ) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 59fd7a4940..bd7b1399e1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.spaces.SpaceService @@ -67,6 +68,7 @@ interface MatrixClient { val sessionCoroutineScope: CoroutineScope val ignoredUsersFlow: StateFlow> val roomMembershipObserver: RoomMembershipObserver + val ownBeaconInfoUpdates: Flow suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? suspend fun getRoom(roomId: RoomId): BaseRoom? suspend fun findDM(userId: UserId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconId.kt new file mode 100644 index 0000000000..358dcd98d1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconId.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.matrix.api.room.location + +import io.element.android.libraries.matrix.api.core.EventId + +typealias BeaconId = EventId diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconInfoUpdate.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconInfoUpdate.kt new file mode 100644 index 0000000000..0b7e9b0f44 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/BeaconInfoUpdate.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.matrix.api.room.location + +import io.element.android.libraries.matrix.api.core.RoomId + +data class BeaconInfoUpdate( + val roomId: RoomId, + val beaconId: BeaconId, + val isLive: Boolean, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationException.kt new file mode 100644 index 0000000000..9b53603042 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationException.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.matrix.api.room.location + +sealed class LiveLocationException(message: String?) : Exception(message) { + class NotLive : LiveLocationException("The beacon event has expired.") + class Network : LiveLocationException("Network error") + class Other(val exception: Exception) : LiveLocationException(exception.message) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt index 3f9c108dc7..4d8bc4638a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -21,6 +21,8 @@ data class LiveLocationShare( val startTimestamp: Long, /** The timestamp when location sharing ends, in milliseconds. */ val endTimestamp: Long, + /** The event id from the beacon info. */ + val beaconId: BeaconId ) data class LastLocation( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 5a1dc9da4b..05e52605dc 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -70,6 +70,7 @@ import io.element.android.libraries.matrix.impl.room.RustRoomFactory import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.location.map import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService import io.element.android.libraries.matrix.impl.roomdirectory.map @@ -113,6 +114,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.matrix.rustcomponents.sdk.AuthData import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails +import org.matrix.rustcomponents.sdk.BeaconInfoListener +import org.matrix.rustcomponents.sdk.BeaconInfoUpdate import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientException import org.matrix.rustcomponents.sdk.IgnoredUsersListener @@ -207,6 +210,15 @@ class RustMatrixClient( analyticsService = analyticsService, ) + override val ownBeaconInfoUpdates = mxCallbackFlow { + val listener = object : BeaconInfoListener { + override fun onUpdate(update: BeaconInfoUpdate) { + trySend(update.map()) + } + } + innerClient.subscribeToOwnBeaconInfoUpdates(listener) + } + override val sessionVerificationService = RustSessionVerificationService( client = innerClient, isSyncServiceReady = syncService.syncState.map { it == SyncState.Running }, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 87ef0815ce..caeef02e8e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow +import io.element.android.libraries.matrix.impl.room.location.map import io.element.android.libraries.matrix.impl.room.location.timedByExpiry import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService @@ -72,6 +73,7 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.KnockRequestsListener +import org.matrix.rustcomponents.sdk.LiveLocationException import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate import org.matrix.rustcomponents.sdk.SendQueueListener @@ -525,12 +527,22 @@ class JoinedRustRoom( override suspend fun stopLiveLocationShare(): Result = withContext(roomDispatcher) { runCatchingExceptions { innerRoom.stopLiveLocationShare() + }.mapFailure { throwable -> + when (throwable) { + is LiveLocationException -> throwable.map() + else -> throwable + } } } override suspend fun sendLiveLocation(geoUri: String): Result = withContext(roomDispatcher) { runCatchingExceptions { innerRoom.sendLiveLocation(geoUri) + }.mapFailure { throwable -> + when (throwable) { + is LiveLocationException -> throwable.map() + else -> throwable + } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/BeaconInfoUpdates.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/BeaconInfoUpdates.kt new file mode 100644 index 0000000000..44be305c02 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/BeaconInfoUpdates.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.matrix.impl.room.location + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate +import org.matrix.rustcomponents.sdk.BeaconInfoUpdate as RustBeaconInfoUpdate + +fun RustBeaconInfoUpdate.map(): BeaconInfoUpdate { + return BeaconInfoUpdate( + roomId = RoomId(roomId), + beaconId = EventId(eventId), + isLive = live + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationException.kt new file mode 100644 index 0000000000..b10f5cd41c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationException.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.matrix.impl.room.location + +import io.element.android.libraries.matrix.api.room.location.LiveLocationException +import org.matrix.rustcomponents.sdk.LiveLocationException as RustLiveLocationException + +fun RustLiveLocationException.map(): LiveLocationException { + return when (this) { + is RustLiveLocationException.Network -> LiveLocationException.Network() + is RustLiveLocationException.NotLive -> LiveLocationException.NotLive() + else -> LiveLocationException.Other(this) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt index 1a341d0dc2..8e8181539f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.impl.room.location +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.LastLocation import io.element.android.libraries.matrix.api.room.location.LiveLocationShare @@ -41,9 +42,9 @@ fun RoomInterface.liveLocationSharesFlow(): Flow> { } } return callbackFlow { - val liveLocationShares = liveLocationsObserver() + val observer = liveLocationsObserver() val shares: MutableList = ArrayList() - val taskHandle = liveLocationShares.subscribe(object : LiveLocationsListener { + val taskHandle = observer.subscribe(object : LiveLocationsListener { override fun onUpdate(updates: List) { for (update in updates) { shares.applyUpdate(update) @@ -53,13 +54,14 @@ fun RoomInterface.liveLocationSharesFlow(): Flow> { }) awaitClose { taskHandle.cancelAndDestroy() - liveLocationShares.destroy() + observer.destroy() } }.buffer(Channel.UNLIMITED) } private fun RustLiveLocationShare.into(): LiveLocationShare { return LiveLocationShare( + beaconId = EventId(beaconId), userId = UserId(userId), lastLocation = lastLocation?.let { LastLocation( @@ -69,6 +71,6 @@ private fun RustLiveLocationShare.into(): LiveLocationShare { ) }, startTimestamp = startTs.toLong(), - endTimestamp = (startTs + timeout).toLong() + endTimestamp = (startTs + timeout).toLong(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt index 41627396ad..ba91dae468 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt @@ -11,6 +11,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow @@ -24,9 +25,9 @@ class TimedLiveLocationSharesFlowTest { @Test fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest { val shares = listOf( - aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000), - aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), - aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000), + aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 1_000), + aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000), + aLiveLocationShare(userId = UserId("@carol:server"), endTimestamp = 3_000), ) flowOf(shares) @@ -56,8 +57,8 @@ class TimedLiveLocationSharesFlowTest { @Test fun `it does not double-emit when a share is already expired on receipt`() = runTest { val shares = listOf( - aLiveLocationShare(userId = "@alice:server", endTimestamp = 500), - aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), + aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 500), + aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000), ) flowOf(shares) @@ -81,8 +82,8 @@ class TimedLiveLocationSharesFlowTest { val upstream = MutableSharedFlow>(extraBufferCapacity = 1) val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000)) val updatedShares = listOf( - aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000), - aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000), + aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 10_000), + aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 6_000), ) upstream @@ -133,15 +134,3 @@ class TimedLiveLocationSharesFlowTest { } } } - -private fun aLiveLocationShare( - userId: String = "@user:server", - endTimestamp: Long, -): LiveLocationShare { - return LiveLocationShare( - userId = UserId(userId), - lastLocation = null, - startTimestamp = 0L, - endTimestamp = endTimestamp, - ) -} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 26bc0af8c1..af28dc37e4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.spaces.SpaceService @@ -107,6 +108,7 @@ class FakeMatrixClient( private val canReportRoomLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, override val ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()), + override val ownBeaconInfoUpdates: Flow = emptyFlow(), private val getMaxUploadSizeResult: () -> Result = { lambdaError() }, private val getJoinedRoomIdsResult: () -> Result> = { Result.success(emptySet()) }, private val getRecentEmojisLambda: () -> Result> = { Result.success(emptyList()) }, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index d1cd340641..9f7255ab18 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -89,7 +89,7 @@ class FakeJoinedRoom( private val updateJoinRuleResult: (JoinRule) -> Result = { lambdaError() }, private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, private val liveLocationSharesFlow: Flow> = MutableStateFlow(emptyList()), - private val startLiveLocationShareResult: (Long) -> Result = { lambdaError() }, + private val startLiveLocationShareResult: (Long) -> Result = { lambdaError() }, private val stopLiveLocationShareResult: () -> Result = { lambdaError() }, private val sendLiveLocationResult: (String) -> Result = { lambdaError() }, ) : JoinedRoom, BaseRoom by baseRoom { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/location/LiveLocationFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/location/LiveLocationFixture.kt new file mode 100644 index 0000000000..23730c8886 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/location/LiveLocationFixture.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.matrix.test.room.location + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LastLocation +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID + +fun aLiveLocationShare( + beaconId: EventId = AN_EVENT_ID, + userId: UserId = A_USER_ID, + geoUri: String = "geo:48.8584,2.2945", + timestamp: Long = 0L, + startTimestamp: Long = 0L, + endTimestamp: Long = Long.MAX_VALUE, + assetType: AssetType = AssetType.SENDER, +): LiveLocationShare { + return LiveLocationShare( + beaconId = beaconId, + userId = userId, + lastLocation = LastLocation( + geoUri = geoUri, + timestamp = timestamp, + assetType = assetType, + ), + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + ) +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index 476658946a..df8dc8acc9 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -23,6 +23,9 @@ interface AppPreferencesStore { suspend fun setTheme(theme: String) fun getThemeFlow(): Flow + suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) + fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") suspend fun setHideInviteAvatars(hide: Boolean?) @Deprecated("Use MediaPreviewService instead. Kept only for migration.") diff --git a/libraries/preferences/impl/build.gradle.kts b/libraries/preferences/impl/build.gradle.kts index 0478d303ea..73327a69a2 100644 --- a/libraries/preferences/impl/build.gradle.kts +++ b/libraries/preferences/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -26,4 +27,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) implementation(projects.libraries.sessionStorage.api) + testCommonDependencies(libs) + testImplementation(projects.libraries.preferences.test) } diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index 6856f8bdb6..44260461da 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.preferences.impl.store import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -28,6 +29,7 @@ private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseU private val themeKey = stringPreferencesKey("theme") private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars") private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") +private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") @@ -79,6 +81,18 @@ class DefaultAppPreferencesStore( } } + override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) { + store.edit { prefs -> + prefs[liveLocationMinimumDistanceUpdateKey] = value + } + } + + override fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow { + return store.data.map { prefs -> + prefs[liveLocationMinimumDistanceUpdateKey] ?: 10 + } + } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override fun getHideInviteAvatarsFlow(): Flow { return store.data.map { prefs -> diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt new file mode 100644 index 0000000000..c52d1648ac --- /dev/null +++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Element Creations 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.libraries.preferences.impl.store + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultAppPreferencesStoreTest { + private val buildMeta = BuildMeta( + buildType = BuildType.DEBUG, + isDebuggable = true, + applicationName = "Element X", + productionApplicationName = "Element", + desktopApplicationName = "Element Desktop", + applicationId = "io.element.android", + isEnterpriseBuild = false, + lowPrivacyLoggingEnabled = false, + versionName = "1.0.0", + versionCode = 1, + gitRevision = "test", + gitBranchName = "test", + flavorDescription = "test", + flavorShortDescription = "test", + ) + + @Test + fun `live location minimum distance defaults to 10`() = runTest { + val store = DefaultAppPreferencesStore( + buildMeta = buildMeta, + preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + ) + + assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(10) + } + + @Test + fun `live location minimum distance persists updates`() = runTest { + val store = DefaultAppPreferencesStore( + buildMeta = buildMeta, + preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + ) + + store.setLiveLocationMinimumDistanceInMetersUpdate(25) + + assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25) + } +} diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index 6e7d22a568..152de12e99 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -21,12 +21,14 @@ class InMemoryAppPreferencesStore( hideInviteAvatars: Boolean? = null, timelineMediaPreviewValue: MediaPreviewValue? = null, theme: String? = null, + liveLocationMinimumDistanceUpdate: Int = 10, logLevel: LogLevel = LogLevel.INFO, traceLockPacks: Set = emptySet(), ) : AppPreferencesStore { private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) + private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val logLevel = MutableStateFlow(logLevel) private val tracingLogPacks = MutableStateFlow(traceLockPacks) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) @@ -56,6 +58,14 @@ class InMemoryAppPreferencesStore( return theme } + override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) { + liveLocationMinimumDistanceUpdate.value = value + } + + override fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow { + return liveLocationMinimumDistanceUpdate + } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override fun getHideInviteAvatarsFlow(): Flow { return hideInviteAvatars diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt index ff7119b647..367052eaad 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt @@ -53,4 +53,5 @@ object NotificationIdProvider { enum class ForegroundServiceType { INCOMING_CALL, ONGOING_CALL, + LIVE_LOCATION, } diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt index fdf5cc5f1b..a7c3a6837b 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt @@ -10,12 +10,13 @@ package io.element.android.libraries.sessionstorage.test.observer import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import java.util.concurrent.CopyOnWriteArraySet class FakeSessionObserver : SessionObserver { - private val _listeners = mutableListOf() + private val _listeners = CopyOnWriteArraySet() val listeners: List - get() = _listeners + get() = _listeners.toList() override fun addListener(listener: SessionListener) { _listeners.add(listener) diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt index d6effec06f..33c2ee133b 100644 --- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt @@ -34,6 +34,8 @@ interface AppForegroundStateService { */ val isSyncingNotificationEvent: StateFlow + val isSharingLiveLocation: StateFlow + /** * Start observing the foreground state. */ @@ -53,4 +55,6 @@ interface AppForegroundStateService { * Update the active state for the syncing notification event flow. */ fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean) + + fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) } diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt index c9fa31caca..9dd37af732 100644 --- a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt @@ -20,6 +20,8 @@ class DefaultAppForegroundStateService : AppForegroundStateService { override val isSyncingNotificationEvent = MutableStateFlow(false) override val hasRingingCall = MutableStateFlow(false) + override val isSharingLiveLocation = MutableStateFlow(false) + private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle } override fun startObservingForeground() { @@ -38,6 +40,10 @@ class DefaultAppForegroundStateService : AppForegroundStateService { this.isSyncingNotificationEvent.value = isSyncingNotificationEvent } + override fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) { + this.isSharingLiveLocation.value = isSharingLiveLocation + } + private val lifecycleObserver = LifecycleEventObserver { _, _ -> isInForeground.value = getCurrentState() } private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppForegroundStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppForegroundStateService.kt index a61733cc22..9174b92684 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppForegroundStateService.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppForegroundStateService.kt @@ -16,12 +16,15 @@ class FakeAppForegroundStateService( initialIsInCallValue: Boolean = false, initialIsSyncingNotificationEventValue: Boolean = false, initialHasRingingCall: Boolean = false, + initialIsSharingLiveLocation: Boolean = false, ) : AppForegroundStateService { override val isInForeground = MutableStateFlow(initialForegroundValue) override val isInCall = MutableStateFlow(initialIsInCallValue) override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue) override val hasRingingCall = MutableStateFlow(initialHasRingingCall) + override val isSharingLiveLocation = MutableStateFlow(initialIsSharingLiveLocation) + override fun startObservingForeground() { // No-op } @@ -41,4 +44,8 @@ class FakeAppForegroundStateService( override fun updateHasRingingCall(hasRingingCall: Boolean) { this.hasRingingCall.value = hasRingingCall } + + override fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) { + this.isSharingLiveLocation.value = isSharingLiveLocation + } } diff --git a/tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Day_0_en.png new file mode 100644 index 0000000000..c431ee02e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aaf113646fa3a8ffd57528d3e97eec05a8d80b99a3bbd7770266353dbe6abd64 +size 10217 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Night_0_en.png new file mode 100644 index 0000000000..e3a3776919 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53d9deec2a6295fe0155ce2986463a3b598436c0515ef87e0905c62c25970420 +size 9647 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png index cae8f75b66..afd74655d9 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798 -size 17155 +oid sha256:c9ebf3725fa875994cf1a15b30e2d4d533c2a9281f0f7d5d9f83f8685ba384d1 +size 18637 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png index 29f70fc9b1..30fad74bc6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09 -size 16460 +oid sha256:65c321199578618012d27afe478c0eaf6a67c44101e7d1d1c51a4c6c1fa9b93a +size 17897 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png index cce7a48382..0c9a67252c 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a97492422d54a6d6666c1ade693dc9b63bc9ca07c17d6c1f787c081984c09f68 -size 42470 +oid sha256:9f3fb75974ce37fc3ce5e303ab5573a0d9a769f3c079de5b0f6462b40e53c1cd +size 26713 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_7_en.png new file mode 100644 index 0000000000..e8ae396119 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddaf978cdf3e70b01fbee75e7e7290fa390e749b48ae192fe7da0dcaa9a1d4dc +size 38417 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_8_en.png new file mode 100644 index 0000000000..cce7a48382 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a97492422d54a6d6666c1ade693dc9b63bc9ca07c17d6c1f787c081984c09f68 +size 42470 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png index 541b2a97e1..06ec0c10e9 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:038e12f3caeef6ac8d5389b7cdc68138e089dcac335d0a5904adc55c9bcb7b1c -size 40642 +oid sha256:4031ae26bb8465a61021c15b6166a2186f8f99d7a4dbad7bb28aaf83493cbe69 +size 25907 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_7_en.png new file mode 100644 index 0000000000..7761c87d2e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9ed7610fbdabb3e88c35e2832d06566431d472ad95890c2fdcbb6d1b43d8feb +size 36751 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png new file mode 100644 index 0000000000..541b2a97e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:038e12f3caeef6ac8d5389b7cdc68138e089dcac335d0a5904adc55c9bcb7b1c +size 40642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png index bdb2a689ac..def7635efc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82ed52f907048490ffb63ea9b33274703c1d92d4131cca0320816cc6949bea5e -size 113727 +oid sha256:b9f2f0e8ba6829cb3d83dcc0c6d3ed5a91a0346771f84adb6731ba5fdac01f4d +size 122009 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png index 5d9f6ab2ba..c6a916891b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae3a754c163f83e69ab062c8e638a6e620948081d70105ddb00c32dda7c2ba74 -size 119927 +oid sha256:459141f4442bade537f755d56fe0b4569980a26b92f23409c477802462084b3c +size 122118 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png index 01d01e6806..07613eb447 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:757d555f948a637952aed4b0ad2745212f6a9ab279d172b0a1649fdf547c5a0c -size 120027 +oid sha256:7f85b9e88dca466b567769291fb69afac535135c3a07f412d6b8203968460519 +size 120940 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png index 6962ed5f4d..f06f86068a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb0d86877e5d049d618836846cd8ea97b40d1d66e7bd8d50639280fb3e829372 -size 38499 +oid sha256:00da192b3b46897c9d50986ecd84e07332cd2b490ebc8f3893a9497e72f18af8 +size 41478 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png index 7b0ba78a2c..140a3c3446 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b41879f5159a126b0641c66bed13470756a914cc038dd239d40d359661784b71 -size 40696 +oid sha256:c5a9f9104c9544e32ab0ab67ce03dcbef51e8613c1444aec866ff9bf60f6d241 +size 41683 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png index 8ee52bca11..4cb5fc6e48 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38fa52e6e755d86aa79a029427f04146a3939ac2038cc15717f5aec84ce3be20 -size 40870 +oid sha256:c015bae5b3ce8c4447c62a13fb74d855c23b0e41bf0b128a1834d30781c0d1f8 +size 41110 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png index a63b53d2c1..157e74f7f0 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7fdd2e68114457368c5d19fe117b2d5a86a02ca475925c9fae0269ff92f5144 -size 66312 +oid sha256:dc02a7c7b38753d9509b3cb0fb2eb0e29b6d6c79b8471ba4dc7dd5b81b13d15b +size 50892 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png new file mode 100644 index 0000000000..a63b53d2c1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7fdd2e68114457368c5d19fe117b2d5a86a02ca475925c9fae0269ff92f5144 +size 66312 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png index b8d8a2cf4c..e4a14ae551 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cb21bd5e7d348d1d07ca4b6a360f26a4735cc9760fdd7e3b4b1bcde32da6f08 -size 62655 +oid sha256:39430896a687266d7a60890fdac5dbca6d36c87fe30d07619c9c50e23c3f77be +size 56972 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_9_en.png index 157e74f7f0..b8d8a2cf4c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc02a7c7b38753d9509b3cb0fb2eb0e29b6d6c79b8471ba4dc7dd5b81b13d15b -size 50892 +oid sha256:1cb21bd5e7d348d1d07ca4b6a360f26a4735cc9760fdd7e3b4b1bcde32da6f08 +size 62655 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png index d2b5044048..74826e1e9e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52441a5e250b027ddd40ee32754a63c6206762084f47ecfa7e057e2ac77e78a8 -size 69120 +oid sha256:805245ffe7f4e99a0f5927b89bcaa6ab8eaf7cc603d352b14b8109e76eecbdf2 +size 51742 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png new file mode 100644 index 0000000000..d2b5044048 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52441a5e250b027ddd40ee32754a63c6206762084f47ecfa7e057e2ac77e78a8 +size 69120 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png index 6e371532de..1ad21c4d64 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86273e812cbc6245527c3d1f138111ece99375aec0d68728882b656b01687bff -size 64392 +oid sha256:289819914863942caad5202131efddd9b880b4af64a36d4b45fb547b5b1ec47f +size 55916 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_9_en.png index 74826e1e9e..6e371532de 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:805245ffe7f4e99a0f5927b89bcaa6ab8eaf7cc603d352b14b8109e76eecbdf2 -size 51742 +oid sha256:86273e812cbc6245527c3d1f138111ece99375aec0d68728882b656b01687bff +size 64392 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_0_en.png index da80c7af53..08966f9b39 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:645a7fb89953ff6e72119a105ac966cabaf8ca6a68f3023534301e3471b942f2 -size 49262 +oid sha256:42fd1d9b089b026a9edf1a0ecb71da47a4517062cc19f61e16e70f9b3276ab30 +size 59658 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_1_en.png index 6aa030a83a..25f2f73063 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea52fd146c53b690c1273b090c6b66701a8f40d80eb1dc693898c3dbf8b24def -size 49115 +oid sha256:314ffd2ccda8d0315a27e0cf08fa6d158ddf38fa0903e1e8d7fe45361254aa0d +size 59525 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_2_en.png index a31686aaad..c9b289e9d9 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf3e4ffa06a77fbaa9dc9985274d6b1324fb023f5eeb2b9d229af0854b41e236 -size 49095 +oid sha256:dc25ef0688d2aa9be1fcade1dfa282b243cc3c45bde01d3571b2ad9278dcda74 +size 59509 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_3_en.png index 421367fee7..3398dc3826 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f68a09667dc19c935decb5b640bed2410dd16ae88c0d0ab6ca410225e48a87da -size 49111 +oid sha256:229b570a3c938e75f5d7b33c7fec89be9582ec06aa85dfa587b995219b145e88 +size 59517 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_4_en.png index fbcd0538aa..6767c03276 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5defae968cccb0e78ceff23912a364cf72f9ed61ea6ae3cc9770af9c39b05f9d -size 49035 +oid sha256:9c9caa916637c212f2cf1e5d175a9cf096b83606e1161b7c2bb1c8ddcb142e48 +size 59363 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_5_en.png index 7a3e1e9682..e9434a4041 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c590f67875f9bdc460ab82b59cd3c9e11147bfadc59028552071b64fd440f211 -size 49259 +oid sha256:296c4b7449992b4aaa478038db9b6dd4e687ca05f82cc351db258e3f178adb6f +size 59656 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_6_en.png index 9c788eb3ab..dcbfd047c6 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d48af05c854a8f313f10aaa774e4268756dcf7e0c2bea0e138dc6cd84137673 -size 48995 +oid sha256:f13aab730039d52c1d92e5ed3ad601095cf7c8b3bd76ddf2303f2006ea6f6092 +size 59193 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_7_en.png index 9d375b0b74..3526e1e6d7 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1507eeffcfacfcf6c84494f09d89ef3478748ae31f8fee4a9922e4188ccb454 -size 48563 +oid sha256:961959403b1763abd537e05865d86475161e650e61e51e65202522d08faad55b +size 58771 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_8_en.png index fac0ba3da5..c1707ebef8 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewBlack_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4146fd85887aca1a0a65ef559976c1436d1fc2b0de522295f27bfaef1d904ea4 -size 55410 +oid sha256:2e5df1191bf18d6c922ca3d2a22584c98c4b871254512ea86cfb324fddf8a2d0 +size 62520 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png index 9feee4d98d..880c259789 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b832525da2a7744eff8165c842652bfff4742b728b000b1c8ef8be81bca75efe -size 47052 +oid sha256:ad35559b964b2d1197b256348e175a8082bffc9247024a546eea86655c9c0d8e +size 56855 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png index 9a4a40c0e9..c3e7180e3b 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ae8264008b6396f31332263472c910b492fffdb9dbf7d0186b44a272476c02c -size 46903 +oid sha256:94b94eca0502a081fbb862c426b5063275ac948c8d998e34f40e362a82591d75 +size 56707 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png index fa74fd4b96..bb6e078571 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4842f6e88df23ae0050f0052ebe9581d6c19ac5e29a0c553716012713fbb00aa -size 46901 +oid sha256:46bd8afffd3a9c4cfb92c7d9ea2c2d0a3c4942115a80809d6bbc16037e2839a1 +size 56705 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png index 5f9af21b6e..c0ea01e3b0 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7888dbb0ed5460c54796a6b2c232945bd4449e57feda4aed89ddce097a3c35b -size 46911 +oid sha256:5fc17122d1466b14e85a49165bd3b462756181ec26cbce280437f015734c8e40 +size 56716 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png index fe34c6d5c1..efc3e893a9 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:215745acc9dc0c86e239acf614c94f1cd46e96cf1b6dd7cdca514d7a4a82f835 -size 46742 +oid sha256:12cb6a8ee6846a0f44d5e14599fd7c6dbf97da97d35c6b6c83545d601a246efe +size 56563 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png index 9df6b272e4..7ee27433d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5d099940f91cb3e520f6eb6cbbc075bc57817f3e46e3f9fb169a36b408388f1 -size 47046 +oid sha256:1546f59a5be6b5f8883729c4b83005ebeb990188a86c100ba384da46b2a1da7e +size 56853 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png index f56d4e2115..aae9546e31 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce9c49a9d136d3bae5fb366fc47d3f48f8fabbe6963d0e53166dc1a5ec4cbc20 -size 46695 +oid sha256:714782c0f439f933f6e0127ccf54b6e09138fa23d44a3596017a23665131c119 +size 56498 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png index f19645feef..efadf6c0dc 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed86c0b3137dcd650d7ba65d7ec009146234b4e0b983b1ab8b56974000698201 -size 46242 +oid sha256:9a58efd0afbc84c88d8635738d3bb25826c4d25d790d4eaa1aba0e9efafc3b56 +size 56043 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png index 2b725b9e8f..1547c5289c 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dbdf230e73c3fd93787030d6f5eac65b6a8131bf2eccb07fcd8474f191b87b06 -size 52563 +oid sha256:9d502f4f9ccf2d3eff614d9ed2b751a252acd05c1bf5cf4a8b11775c45725c3d +size 59427 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png index f9a16c247a..1cea623bff 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d57215dd58ace85ad235fb7f95ef592bfddd3c21afbf183bb5d31fbf772b69a -size 48900 +oid sha256:7c80e9985aec6b183e33f12ecb884753d1b8f67927d6d5713ea946ea8f5d6dd6 +size 59300 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png index e662b2dfbe..d32d440b79 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32926b81da50838d883edfd157e7d12cc3d43991556f87e61702b74e693ab6da -size 48798 +oid sha256:5dedcf20e368909de0a6cdeade437bff06b38a1f1b6aba3355968983f1387018 +size 59201 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png index 5ac95223f9..6f672c23b7 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0594480e59710892e480a83c1352a501504ae1bafb4c5c6b672c7dc62b3e75e1 -size 48801 +oid sha256:a9023d044daba4573bb9a3ed4da598bfb5c27081e4f7a75757a4796bdc44573a +size 59207 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png index ca2e552c10..54bd893d1f 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba828796a14ee9e82e01e98a89067649a7f3438a2fcc8e3ce5f69e12e86f86b0 -size 48777 +oid sha256:77eb9f159dbfa30e6f750500c2adf21b18cb5f1dfb7a574be68683db24803225 +size 59174 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png index 6f741da48f..e945e52dfa 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ebb88fa29993c785e6ca82543e7eeafefe365c0018a4f22852b988dc35db4a5 -size 48716 +oid sha256:3dca50c7a0f0d7ce96388a01456871decc76285384d2573161773219c79fb876 +size 59060 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png index 1a12af9468..02c571c605 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2521e5352ee6030f7a0fd8304b19f6a7a95fb6364d43f412eeef03799c8c0f76 -size 48901 +oid sha256:fe4181c3e521f1f788dd06e3cfb1ca078655852415dfd4f26f5480dd47bf084c +size 59303 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png index 962991ff8f..7da66a622f 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6dfcf074e7fa4813218f4bf9e472f706c5581e6fc5c1f7ea1eb9ec422172190f -size 48713 +oid sha256:18602ce14232fd438450782a564354c38ebffb0c56d0c3887d88c47cae53dacf +size 58962 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png index 8917220310..015ecaf7fd 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3195a1871862ce32cdcf068608a565c56e794ff66f14473d5f6355918db79b6 -size 48402 +oid sha256:c3ae0af5031f7f850c40fa6560aa6d70127e006931a3e18cbee04acd7c7aed57 +size 58526 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png index 5fc76943b5..19d1ae2ca8 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f71219202890afcd4f43b5f57cb4d555b723df48f71848b64c8db415067d1d4b -size 55044 +oid sha256:a980564b6406bbe45b9a170a5744e45c9e019556587d433e5fdffd8ab6f1c6fd +size 62034