mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-06 16:43:43 +00:00
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 <android@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org> Co-authored-by: Benoit Marty <benoitm@matrix.org>
This commit is contained in:
@@ -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<LoggedInFlowNode.NavTarget>(
|
||||
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 }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -71,6 +71,7 @@ dependencies {
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.datetime)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
|
||||
+100
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+43
@@ -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<Set<RoomId>>
|
||||
|
||||
/**
|
||||
* 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<Unit>
|
||||
|
||||
/**
|
||||
* 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<Unit>
|
||||
}
|
||||
|
||||
fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow<Boolean> {
|
||||
return sharingRoomIds.mapState { roomId in it }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -9,4 +9,14 @@
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".live.service.LiveLocationSharingService"
|
||||
android:foregroundServiceType="location"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
+4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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),
|
||||
)
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
|
||||
+22
-3
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
+227
@@ -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<ActiveLiveLocationShareManager>())
|
||||
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<RoomId, JoinedRoom>()
|
||||
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
|
||||
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
|
||||
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
|
||||
override val sharingRoomIds: StateFlow<Set<RoomId>> = 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<Unit> = 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<Unit> = 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<Unit> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+94
@@ -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<Unit> = runCatchingExceptions {
|
||||
store.edit { prefs ->
|
||||
prefs[acceptedLiveLocationDisclaimerKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLiveLocationExpiries(): Map<RoomId, Instant> = 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<Unit> = runCatchingExceptions {
|
||||
store.edit { prefs ->
|
||||
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLiveLocationExpiry(roomId: RoomId): Result<Unit> = 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<String>): Map<RoomId, Instant> {
|
||||
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<RoomId, Instant>): Set<String> {
|
||||
return expiries.entries.map { (roomId, expiresAt) ->
|
||||
"${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}"
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
store.edit { prefs -> prefs.clear() }
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
+14
@@ -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)
|
||||
}
|
||||
+98
@@ -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<SessionId, LiveLocationReceiver>()
|
||||
|
||||
private val lastDispatchMillis = AtomicLong(0L)
|
||||
private val lastKnownLocation = AtomicReference<Location?>(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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -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<LocationBindings>().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()
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
+48
-12
@@ -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<ShareLocationState> {
|
||||
@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<Unit>>(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()
|
||||
}
|
||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||
} else {
|
||||
Constraints(constraintsResult.toDialogState())
|
||||
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
|
||||
liveLocationStore.setAcceptedLiveLocationDisclaimer()
|
||||
.onSuccess {
|
||||
dialogState = computeLiveLocationDialogState()
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -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<Unit>,
|
||||
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<LiveLocationDuration>) : Dialog
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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<ShareLocationState>
|
||||
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<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||
): ShareLocationState {
|
||||
return ShareLocationState(
|
||||
@@ -82,6 +96,7 @@ fun aShareLocationState(
|
||||
hasLocationPermission = hasLocationPermission,
|
||||
canShareLiveLocation = canShareLiveLocation,
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
|
||||
+51
-7
@@ -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<Unit>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
||||
data object RequestPermissions : ShowLocationEvent
|
||||
data object OpenAppSettings : ShowLocationEvent
|
||||
data object OpenLocationSettings : ShowLocationEvent
|
||||
data object StopLocationSharing : ShowLocationEvent
|
||||
}
|
||||
|
||||
+12
-1
@@ -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<ShowLocationState> {
|
||||
@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()
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+1
@@ -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(
|
||||
|
||||
+17
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+488
@@ -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<Unit> { }
|
||||
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||
val coordinator = createCoordinator(
|
||||
startService = startServiceRecorder,
|
||||
stopService = stopServiceRecorder
|
||||
)
|
||||
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(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<Unit> { }
|
||||
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||
val coordinator = createCoordinator(
|
||||
startService = startServiceRecorder,
|
||||
stopService = stopServiceRecorder
|
||||
)
|
||||
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(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<BeaconInfoUpdate>(replay = 1)
|
||||
val beaconInfoUpdatesTwo = MutableSharedFlow<BeaconInfoUpdate>(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<BeaconInfoUpdate>(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<BeaconInfoUpdate>(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<Unit> { }
|
||||
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||
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<Unit>> { 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<Unit>> { 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<BeaconInfoUpdate>(replay = 1)
|
||||
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { 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<Unit>> { 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<Unit> { }
|
||||
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||
val sessionObserver = FakeSessionObserver()
|
||||
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(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<Preferences> {
|
||||
var preferences: Preferences = emptyPreferences()
|
||||
return object : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences>
|
||||
get() = flowOf(preferences)
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
preferences = transform(preferences)
|
||||
return preferences
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return createLiveLocationStore(
|
||||
sessionId = sessionId,
|
||||
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||
)
|
||||
}
|
||||
}
|
||||
+115
@@ -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<Location>()
|
||||
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<Location>()
|
||||
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<Location>()
|
||||
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<Location>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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(),
|
||||
|
||||
+242
-5
@@ -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<Preferences> = object : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences> = 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 }
|
||||
)
|
||||
|
||||
+32
@@ -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<ShareLocationEvent>()
|
||||
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<ShareLocationEvent>()
|
||||
setShareLocationView(
|
||||
aShareLocationState(
|
||||
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||
eventSink = eventsRecorder,
|
||||
canShareLiveLocation = true,
|
||||
),
|
||||
navigateUp = EnsureNeverCalled(),
|
||||
)
|
||||
|
||||
clickOn(CommonStrings.action_decline)
|
||||
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(
|
||||
|
||||
+4
-1
@@ -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(),
|
||||
|
||||
+1
-13
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-21
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+129
@@ -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<Preferences> {
|
||||
var preferences: Preferences = mutablePreferencesOf(
|
||||
stringPreferencesKey("live_location_expiries") to "not valid"
|
||||
)
|
||||
return object : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences>
|
||||
get() = flowOf(preferences)
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
preferences = transform(preferences)
|
||||
return preferences
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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<Unit> = { _, _ -> lambdaError() },
|
||||
val stopShareLambda: (roomId: RoomId) -> Result<Unit> = { _ -> lambdaError() },
|
||||
) : ActiveLiveLocationShareManager {
|
||||
private val _sharingRoomIds = MutableStateFlow(emptySet<RoomId>())
|
||||
override val sharingRoomIds: StateFlow<Set<RoomId>> = _sharingRoomIds
|
||||
|
||||
override suspend fun setup() {
|
||||
setupLambda()
|
||||
}
|
||||
|
||||
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = simulateLongTask {
|
||||
startShareLambda(roomId, duration).onSuccess {
|
||||
_sharingRoomIds.update {
|
||||
it + roomId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stopShare(roomId: RoomId): Result<Unit> = simulateLongTask {
|
||||
stopShareLambda(roomId).onSuccess {
|
||||
_sharingRoomIds.update {
|
||||
it - roomId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+1
@@ -26,5 +26,6 @@ interface MessagesNavigator {
|
||||
fun navigateToMember(userId: UserId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToCurrentLiveLocation()
|
||||
fun close()
|
||||
}
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
|
||||
+18
@@ -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<MessagesState> {
|
||||
@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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+3
@@ -80,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
+11
-1
@@ -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
|
||||
@@ -520,6 +521,7 @@ private fun MessagesViewContent(
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
@@ -537,10 +539,18 @@ private fun MessagesViewContent(
|
||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
knockRequestsBannerView()
|
||||
if (state.showLiveLocationShareBanner) {
|
||||
LiveLocationSharingBanner(
|
||||
onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) },
|
||||
onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+3
-2
@@ -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<ActionListState> {
|
||||
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<ActionListState> {
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemLocationContent(),
|
||||
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+5
-1
@@ -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<RoomCallState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
) : Presenter<TimelineState> {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+9
-8
@@ -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(),
|
||||
|
||||
+8
@@ -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<TimelineItemLocationContent, TimelineItemLocationContent> { content ->
|
||||
Presenter { content.ensureActiveLiveLocation() }
|
||||
},
|
||||
),
|
||||
Pair(
|
||||
TimelineItemVoiceContent::class,
|
||||
TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { Presenter { aVoiceMessageState() } },
|
||||
|
||||
+1
@@ -127,6 +127,7 @@ class TimelineItemContentFactory(
|
||||
isActive = itemContent.isLive,
|
||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||
endTimestamp = itemContent.endTimestamp,
|
||||
isOwnUser = sessionId == sender
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||
aTimelineItemAudioContent(),
|
||||
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
|
||||
aTimelineItemVoiceContent(),
|
||||
aTimelineItemLocationContent(),
|
||||
aTimelineItemLocationContent(mode = aStaticLocationMode()),
|
||||
aTimelineItemPollContent(),
|
||||
aTimelineItemNoticeContent(),
|
||||
aTimelineItemRedactedContent(),
|
||||
@@ -36,7 +36,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
|
||||
mode = aLiveLocationMode(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -72,9 +72,10 @@ data class TimelineItemLocationContent(
|
||||
val isActive: Boolean,
|
||||
val endsAt: String,
|
||||
val endTimestamp: Long,
|
||||
val canStop: Boolean = false,
|
||||
val isOwnUser: Boolean,
|
||||
) : Mode {
|
||||
val isLoading = lastKnownLocation == null && isActive
|
||||
val canStopSharing = isActive && isOwnUser
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-26
@@ -17,48 +17,44 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead
|
||||
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
|
||||
override val values: Sequence<TimelineItemLocationContent>
|
||||
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,
|
||||
|
||||
+5
@@ -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()
|
||||
}
|
||||
|
||||
+37
@@ -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<RoomId, Result<Unit>> { 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,
|
||||
)
|
||||
}
|
||||
|
||||
+38
@@ -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<MessagesEvent>()
|
||||
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<MessagesEvent>()
|
||||
val state = aMessagesState(
|
||||
isCurrentlySharingLiveLocationInRoom = true,
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
setMessagesView(state = state)
|
||||
clickOn(CommonStrings.screen_room_live_location_banner)
|
||||
eventsRecorder.assertSingle(MessagesEvent.ShowLiveLocationShare)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
+5
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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<Int?>(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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ data class AdvancedSettingsState(
|
||||
val theme: ThemeOption,
|
||||
val availableThemeOptions: ImmutableList<ThemeOption>,
|
||||
val mediaPreviewConfigState: MediaPreviewConfigState,
|
||||
val liveLocationMinimumDistanceUpdate: Int?,
|
||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||
)
|
||||
|
||||
|
||||
+2
@@ -41,6 +41,7 @@ fun aAdvancedSettingsState(
|
||||
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
|
||||
hideInviteAvatars: Boolean = false,
|
||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||
liveLocationMinimumDistanceUpdate: Int? = 50,
|
||||
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (AdvancedSettingsEvents) -> Unit = {},
|
||||
@@ -56,5 +57,6 @@ fun aAdvancedSettingsState(
|
||||
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
|
||||
setHideInviteAvatarsAction = setHideInviteAvatarsAction
|
||||
),
|
||||
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+96
-1
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+68
-1
@@ -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(),
|
||||
|
||||
+2
@@ -250,6 +250,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
|
||||
state: AdvancedSettingsState,
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAppSettings: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
CompositionLocalProvider(
|
||||
@@ -258,6 +259,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
|
||||
AdvancedSettingsView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onOpenAppSettingsClick = onOpenAppSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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<ImmutableList<UserId>>
|
||||
val roomMembershipObserver: RoomMembershipObserver
|
||||
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
|
||||
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
||||
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
||||
suspend fun findDM(userId: UserId): Result<RoomId?>
|
||||
|
||||
+12
@@ -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
|
||||
+16
@@ -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,
|
||||
)
|
||||
+14
@@ -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)
|
||||
}
|
||||
+2
@@ -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(
|
||||
|
||||
+12
@@ -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 },
|
||||
|
||||
+12
@@ -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<Unit> = withContext(roomDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerRoom.stopLiveLocationShare()
|
||||
}.mapFailure { throwable ->
|
||||
when (throwable) {
|
||||
is LiveLocationException -> throwable.map()
|
||||
else -> throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerRoom.sendLiveLocation(geoUri)
|
||||
}.mapFailure { throwable ->
|
||||
when (throwable) {
|
||||
is LiveLocationException -> throwable.map()
|
||||
else -> throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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
|
||||
)
|
||||
}
|
||||
+19
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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<List<LiveLocationShare>> {
|
||||
}
|
||||
}
|
||||
return callbackFlow {
|
||||
val liveLocationShares = liveLocationsObserver()
|
||||
val observer = liveLocationsObserver()
|
||||
val shares: MutableList<LiveLocationShare> = ArrayList()
|
||||
val taskHandle = liveLocationShares.subscribe(object : LiveLocationsListener {
|
||||
val taskHandle = observer.subscribe(object : LiveLocationsListener {
|
||||
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
|
||||
for (update in updates) {
|
||||
shares.applyUpdate(update)
|
||||
@@ -53,13 +54,14 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
|
||||
})
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
+8
-19
@@ -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<List<LiveLocationShare>>(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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
||||
override val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate> = emptyFlow(),
|
||||
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
|
||||
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
|
||||
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ class FakeJoinedRoom(
|
||||
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
|
||||
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
|
||||
private val liveLocationSharesFlow: Flow<List<LiveLocationShare>> = MutableStateFlow(emptyList()),
|
||||
private val startLiveLocationShareResult: (Long) -> Result<Unit> = { lambdaError() },
|
||||
private val startLiveLocationShareResult: (Long) -> Result<EventId> = { lambdaError() },
|
||||
private val stopLiveLocationShareResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val sendLiveLocationResult: (String) -> Result<Unit> = { lambdaError() },
|
||||
) : JoinedRoom, BaseRoom by baseRoom {
|
||||
|
||||
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
+3
@@ -23,6 +23,9 @@ interface AppPreferencesStore {
|
||||
suspend fun setTheme(theme: String)
|
||||
fun getThemeFlow(): Flow<String?>
|
||||
|
||||
suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int)
|
||||
fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow<Int>
|
||||
|
||||
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||
suspend fun setHideInviteAvatars(hide: Boolean?)
|
||||
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
@@ -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<Int> {
|
||||
return store.data.map { prefs ->
|
||||
prefs[liveLocationMinimumDistanceUpdateKey] ?: 10
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
||||
return store.data.map { prefs ->
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
@@ -21,12 +21,14 @@ class InMemoryAppPreferencesStore(
|
||||
hideInviteAvatars: Boolean? = null,
|
||||
timelineMediaPreviewValue: MediaPreviewValue? = null,
|
||||
theme: String? = null,
|
||||
liveLocationMinimumDistanceUpdate: Int = 10,
|
||||
logLevel: LogLevel = LogLevel.INFO,
|
||||
traceLockPacks: Set<TraceLogPack> = 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<Int> {
|
||||
return liveLocationMinimumDistanceUpdate
|
||||
}
|
||||
|
||||
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
||||
return hideInviteAvatars
|
||||
|
||||
+1
@@ -53,4 +53,5 @@ object NotificationIdProvider {
|
||||
enum class ForegroundServiceType {
|
||||
INCOMING_CALL,
|
||||
ONGOING_CALL,
|
||||
LIVE_LOCATION,
|
||||
}
|
||||
|
||||
+3
-2
@@ -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<SessionListener>()
|
||||
private val _listeners = CopyOnWriteArraySet<SessionListener>()
|
||||
|
||||
val listeners: List<SessionListener>
|
||||
get() = _listeners
|
||||
get() = _listeners.toList()
|
||||
|
||||
override fun addListener(listener: SessionListener) {
|
||||
_listeners.add(listener)
|
||||
|
||||
+4
@@ -34,6 +34,8 @@ interface AppForegroundStateService {
|
||||
*/
|
||||
val isSyncingNotificationEvent: StateFlow<Boolean>
|
||||
|
||||
val isSharingLiveLocation: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
+6
@@ -20,6 +20,8 @@ class DefaultAppForegroundStateService : AppForegroundStateService {
|
||||
override val isSyncingNotificationEvent = MutableStateFlow(false)
|
||||
override val hasRingingCall = MutableStateFlow(false)
|
||||
|
||||
override val isSharingLiveLocation = MutableStateFlow<Boolean>(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)
|
||||
|
||||
+7
@@ -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<Boolean>(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
|
||||
}
|
||||
}
|
||||
|
||||
tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Day_0_en.png
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user