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:
parent
0c657c258a
commit
e49e183178
145 changed files with 2913 additions and 278 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
|
||||
liveLocationStore.setAcceptedLiveLocationDisclaimer()
|
||||
.onSuccess {
|
||||
dialogState = computeLiveLocationDialogState()
|
||||
}
|
||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||
} else {
|
||||
Constraints(constraintsResult.toDialogState())
|
||||
}
|
||||
}
|
||||
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
|
||||
startLiveLocationAction.runUpdatingState {
|
||||
liveLocationShareManager.startShare(
|
||||
roomId = room.roomId,
|
||||
duration = event.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
ShareLocationEvent.RequestPermissions -> {
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
|
|
@ -138,6 +173,7 @@ class ShareLocationPresenter(
|
|||
hasLocationPermission = permissionsState.isAnyGranted,
|
||||
canShareLiveLocation = isLiveLocationSharingEnabled,
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
|||
data object RequestPermissions : ShowLocationEvent
|
||||
data object OpenAppSettings : ShowLocationEvent
|
||||
data object OpenLocationSettings : ShowLocationEvent
|
||||
data object StopLocationSharing : ShowLocationEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,14 +20,15 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
|
|||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
|
|
@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
|
|||
),
|
||||
locationActions: FakeLocationActions = fakeLocationActions,
|
||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
) = ShowLocationPresenter(
|
||||
mode = mode,
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
|
|
@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
|
|||
dateFormatter = fakeDateFormatter,
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
val initialState = awaitItem()
|
||||
|
||||
|
|
@ -464,23 +467,3 @@ class ShowLocationPresenterTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLiveLocationShare(
|
||||
userId: UserId,
|
||||
geoUri: String = "geo:48.8584,2.2945",
|
||||
timestamp: Long = 0L,
|
||||
startTimestamp: Long = 0L,
|
||||
endTimestamp: Long = Long.MAX_VALUE,
|
||||
assetType: AssetType = AssetType.SENDER,
|
||||
): LiveLocationShare {
|
||||
return LiveLocationShare(
|
||||
userId = userId,
|
||||
lastLocation = LastLocation(
|
||||
geoUri = geoUri,
|
||||
timestamp = timestamp,
|
||||
assetType = assetType,
|
||||
),
|
||||
startTimestamp = startTimestamp,
|
||||
endTimestamp = endTimestamp,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -26,5 +26,6 @@ interface MessagesNavigator {
|
|||
fun navigateToMember(userId: UserId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToCurrentLiveLocation()
|
||||
fun close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.location.api.LiveLocationSharingBanner
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
|
|
@ -205,15 +206,15 @@ fun MessagesView(
|
|||
val expandableState = rememberExpandableBottomSheetLayoutState()
|
||||
ExpandableBottomSheetLayout(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding()
|
||||
.onSizeChanged { size ->
|
||||
// Let the composer takes at max half of the available height.
|
||||
// The value will be different if the soft keyboard is displayed
|
||||
// or not.
|
||||
maxComposerHeightPx = (size.height * 0.5f).toInt()
|
||||
},
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding()
|
||||
.onSizeChanged { size ->
|
||||
// Let the composer takes at max half of the available height.
|
||||
// The value will be different if the soft keyboard is displayed
|
||||
// or not.
|
||||
maxComposerHeightPx = (size.height * 0.5f).toInt()
|
||||
},
|
||||
content = {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
|
|
@ -250,8 +251,8 @@ fun MessagesView(
|
|||
content = { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
MessagesViewContent(
|
||||
state = state,
|
||||
|
|
@ -288,10 +289,10 @@ fun MessagesView(
|
|||
|
||||
SuggestionsPickerView(
|
||||
modifier = Modifier
|
||||
.shadow(10.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.align(Alignment.BottomStart)
|
||||
.heightIn(max = 230.dp),
|
||||
.shadow(10.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.align(Alignment.BottomStart)
|
||||
.heightIn(max = 230.dp),
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName,
|
||||
roomAvatarData = state.roomAvatar,
|
||||
|
|
@ -467,9 +468,9 @@ private fun MessagesViewContent(
|
|||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
AttachmentsBottomSheet(
|
||||
state = state.composerState,
|
||||
|
|
@ -520,25 +521,34 @@ private fun MessagesViewContent(
|
|||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
fun focusOnPinnedEvent(eventId: EventId) {
|
||||
state.timelineState.eventSink(
|
||||
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
fun focusOnPinnedEvent(eventId: EventId) {
|
||||
state.timelineState.eventSink(
|
||||
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
||||
)
|
||||
}
|
||||
PinnedMessagesBannerView(
|
||||
state = state.pinnedMessagesBannerState,
|
||||
onClick = ::focusOnPinnedEvent,
|
||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
if (state.showLiveLocationShareBanner) {
|
||||
LiveLocationSharingBanner(
|
||||
onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) },
|
||||
onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) }
|
||||
)
|
||||
}
|
||||
PinnedMessagesBannerView(
|
||||
state = state.pinnedMessagesBannerState,
|
||||
onClick = ::focusOnPinnedEvent,
|
||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -587,9 +597,9 @@ private fun MessagesViewComposerBottomSheetContents(
|
|||
private fun CantSendMessageBanner() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(16.dp),
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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() } },
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ class TimelineItemContentFactory(
|
|||
isActive = itemContent.isLive,
|
||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||
endTimestamp = itemContent.endTimestamp,
|
||||
isOwnUser = sessionId == sender
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ data class AdvancedSettingsState(
|
|||
val theme: ThemeOption,
|
||||
val availableThemeOptions: ImmutableList<ThemeOption>,
|
||||
val mediaPreviewConfigState: MediaPreviewConfigState,
|
||||
val liveLocationMinimumDistanceUpdate: Int?,
|
||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -53,4 +53,5 @@ object NotificationIdProvider {
|
|||
enum class ForegroundServiceType {
|
||||
INCOMING_CALL,
|
||||
ONGOING_CALL,
|
||||
LIVE_LOCATION,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aaf113646fa3a8ffd57528d3e97eec05a8d80b99a3bbd7770266353dbe6abd64
|
||||
size 10217
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:53d9deec2a6295fe0155ce2986463a3b598436c0515ef87e0905c62c25970420
|
||||
size 9647
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798
|
||||
size 17155
|
||||
oid sha256:c9ebf3725fa875994cf1a15b30e2d4d533c2a9281f0f7d5d9f83f8685ba384d1
|
||||
size 18637
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09
|
||||
size 16460
|
||||
oid sha256:65c321199578618012d27afe478c0eaf6a67c44101e7d1d1c51a4c6c1fa9b93a
|
||||
size 17897
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a97492422d54a6d6666c1ade693dc9b63bc9ca07c17d6c1f787c081984c09f68
|
||||
size 42470
|
||||
oid sha256:9f3fb75974ce37fc3ce5e303ab5573a0d9a769f3c079de5b0f6462b40e53c1cd
|
||||
size 26713
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ddaf978cdf3e70b01fbee75e7e7290fa390e749b48ae192fe7da0dcaa9a1d4dc
|
||||
size 38417
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue