mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-07 01:19:17 +00:00
Feature : share live location (#6741)
* First live location sharing sending implementation * Simplify logic around canStop sharing * Add some debug logs around LiveLocationSharingService * Add LiveLocationException * Expose beaconId to identify the current share * Throttle live location instead of debouncing * Keep sync alive when sharing live location * Improve LiveLocation sharing * Show LiveLocationDisclaimer * Read minDistanceUpdate in LiveLocationSharingService * Set minDistanceUpdate in AdvancedSettings * Display banner in room when sharing live location * Fix tests around LiveLocationSharing * Ensure shares are properly restarted/stopped when app is re-launched * Ensure LLS data is cleared when session is removed * Update and fix LLS tests * Handle Start LLS in ui * Add check LLS permissions * Remove hardcoded strings * Fix quality and format * Create DeviceLocationProvider so we can share location data between sources (presenter/live location service) * Update screenshots * Fix warning * Do not try to stop if it was not sharing * Revert "Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)" This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8. * Tweak location provider config values * Address PR review remarks * Fix ktlint * Update screenshots * Fix some tests after merging develop * Adjust TimelineItemLocationView ui to match figma * Update screenshots * Documentation and cleanup * Remove temporary resource --------- Co-authored-by: ElementBot <android@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org> Co-authored-by: Benoit Marty <benoitm@matrix.org>
This commit is contained in:
@@ -54,6 +54,7 @@ import io.element.android.features.ftue.api.state.FtueService
|
|||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.home.api.HomeEntryPoint
|
import io.element.android.features.home.api.HomeEntryPoint
|
||||||
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
|
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.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
||||||
@@ -151,6 +152,7 @@ class LoggedInFlowNode(
|
|||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
|
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
|
||||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||||
|
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Placeholder,
|
initialElement = NavTarget.Placeholder,
|
||||||
@@ -211,6 +213,7 @@ class LoggedInFlowNode(
|
|||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
sessionEnterpriseService.init()
|
sessionEnterpriseService.init()
|
||||||
|
activeLiveLocationShareManager.setup()
|
||||||
}
|
}
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onCreate = {
|
onCreate = {
|
||||||
@@ -219,7 +222,6 @@ class LoggedInFlowNode(
|
|||||||
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
||||||
matrixClient.sessionVerificationService.setListener(verificationListener)
|
matrixClient.sessionVerificationService.setListener(verificationListener)
|
||||||
mediaPreviewConfigMigration()
|
mediaPreviewConfigMigration()
|
||||||
|
|
||||||
sessionCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
// Wait for the network to be connected before pre-fetching the max file upload size
|
// Wait for the network to be connected before pre-fetching the max file upload size
|
||||||
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
|
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
|
||||||
|
|||||||
@@ -89,16 +89,14 @@ class SyncOrchestrator(
|
|||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
internal fun observeStates() = coroutineScope.launch {
|
internal fun observeStates() = coroutineScope.launch {
|
||||||
Timber.tag(tag).d("start observing the app and network state")
|
Timber.tag(tag).d("start observing the app and network state")
|
||||||
|
val isAppActiveFlows = listOf(
|
||||||
val isAppActiveFlow = combine(
|
|
||||||
appForegroundStateService.isInForeground,
|
appForegroundStateService.isInForeground,
|
||||||
appForegroundStateService.isInCall,
|
appForegroundStateService.isInCall,
|
||||||
appForegroundStateService.isSyncingNotificationEvent,
|
appForegroundStateService.isSyncingNotificationEvent,
|
||||||
appForegroundStateService.hasRingingCall,
|
appForegroundStateService.hasRingingCall,
|
||||||
) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
|
appForegroundStateService.isSharingLiveLocation
|
||||||
isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
|
)
|
||||||
}
|
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
|
||||||
|
|
||||||
combine(
|
combine(
|
||||||
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
||||||
syncService.syncState.debounce(100.milliseconds),
|
syncService.syncState.debounce(100.milliseconds),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.datetime)
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
}
|
}
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.api
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LiveLocationSharingBanner(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onStopClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(ElementTheme.colors.bgCanvasDefault)
|
||||||
|
.drawBannerBorder(ElementTheme.colors.separatorPrimary)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.LocationPinSolid(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.screen_room_live_location_banner),
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
text = stringResource(CommonStrings.action_stop),
|
||||||
|
onClick = onStopClick,
|
||||||
|
destructive = true,
|
||||||
|
size = ButtonSize.Small,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.drawBannerBorder(borderColor: Color): Modifier = drawBehind {
|
||||||
|
val strokeWidth = 1.dp.toPx()
|
||||||
|
val bottomY = size.height - strokeWidth / 2
|
||||||
|
drawLine(
|
||||||
|
color = borderColor,
|
||||||
|
start = Offset(0f, strokeWidth / 2),
|
||||||
|
end = Offset(size.width, strokeWidth / 2),
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = borderColor,
|
||||||
|
start = Offset(0f, bottomY),
|
||||||
|
end = Offset(size.width, bottomY),
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LiveLocationSharingBannerPreview() = ElementPreview {
|
||||||
|
LiveLocationSharingBanner(
|
||||||
|
onClick = {},
|
||||||
|
onStopClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.api.live
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
interface ActiveLiveLocationShareManager {
|
||||||
|
/** All rooms currently sharing live location on this device. */
|
||||||
|
val sharingRoomIds: StateFlow<Set<RoomId>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the manager.
|
||||||
|
* This will restart or stop current location sharing and set the listener on the SDK
|
||||||
|
* and the session manager.
|
||||||
|
*/
|
||||||
|
suspend fun setup()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts live location sharing in the given room.
|
||||||
|
* Calls room.startLiveLocationShare() on the SDK, registers the share,
|
||||||
|
* and starts the foreground GPS service if not already running.
|
||||||
|
*/
|
||||||
|
suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops live location sharing in the given room.
|
||||||
|
* Calls room.stopLiveLocationShare() on the SDK, removes the share,
|
||||||
|
* and stops the foreground service if no shares remain.
|
||||||
|
*/
|
||||||
|
suspend fun stopShare(roomId: RoomId): Result<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow<Boolean> {
|
||||||
|
return sharingRoomIds.mapState { roomId in it }
|
||||||
|
}
|
||||||
@@ -37,10 +37,16 @@ dependencies {
|
|||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
implementation(projects.services.appnavstate.api)
|
||||||
implementation(libs.accompanist.permission)
|
implementation(libs.accompanist.permission)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.dateformatter.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)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
@@ -50,4 +56,7 @@ dependencies {
|
|||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.features.messages.test)
|
testImplementation(projects.features.messages.test)
|
||||||
testImplementation(projects.libraries.featureflag.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_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_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>
|
</manifest>
|
||||||
|
|||||||
+4
@@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
|
|||||||
data object PermissionRationale : LocationConstraintsCheck
|
data object PermissionRationale : LocationConstraintsCheck
|
||||||
data object PermissionDenied : LocationConstraintsCheck
|
data object PermissionDenied : LocationConstraintsCheck
|
||||||
data object LocationServiceDisabled : LocationConstraintsCheck
|
data object LocationServiceDisabled : LocationConstraintsCheck
|
||||||
|
data object NotEnoughPowerLevel : LocationConstraintsCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkLocationConstraints(
|
fun checkLocationConstraints(
|
||||||
permissionsState: PermissionsState,
|
permissionsState: PermissionsState,
|
||||||
locationActions: LocationActions,
|
locationActions: LocationActions,
|
||||||
|
sendLiveLocationPermissions: SendLiveLocationPermissions,
|
||||||
): LocationConstraintsCheck {
|
): LocationConstraintsCheck {
|
||||||
return when {
|
return when {
|
||||||
|
!sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
|
||||||
permissionsState.isAnyGranted -> {
|
permissionsState.isAnyGranted -> {
|
||||||
if (locationActions.isLocationEnabled()) {
|
if (locationActions.isLocationEnabled()) {
|
||||||
LocationConstraintsCheck.Success
|
LocationConstraintsCheck.Success
|
||||||
@@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
|
|||||||
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
||||||
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
||||||
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
|
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
|
||||||
|
LocationConstraintsCheck.NotEnoughPowerLevel -> LocationConstraintsDialogState.NotEnoughPowerLevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions to send beacon and beacon_info events in the room.
|
||||||
|
*/
|
||||||
|
data class SendLiveLocationPermissions(
|
||||||
|
val canSendBeacon: Boolean,
|
||||||
|
val canSendBeaconInfo: Boolean,
|
||||||
|
) {
|
||||||
|
val hasAll = canSendBeaconInfo && canSendBeacon
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT = SendLiveLocationPermissions(canSendBeacon = false, canSendBeaconInfo = false)
|
||||||
|
val GRANTED = SendLiveLocationPermissions(canSendBeacon = true, canSendBeaconInfo = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RoomPermissions.sendLiveLocationPermissions(): SendLiveLocationPermissions {
|
||||||
|
return SendLiveLocationPermissions(
|
||||||
|
canSendBeaconInfo = canOwnUserSendState(StateEventType.BeaconInfo),
|
||||||
|
canSendBeacon = canOwnUserSendMessage(MessageEventType.Beacon),
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
@@ -10,6 +10,8 @@ package io.element.android.features.location.impl.common.ui
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.ui.res.stringResource
|
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.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
|
|||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
submitText = stringResource(CommonStrings.action_continue),
|
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 PermissionRationale : LocationConstraintsDialogState
|
||||||
data object PermissionDenied : LocationConstraintsDialogState
|
data object PermissionDenied : LocationConstraintsDialogState
|
||||||
data object LocationServiceDisabled : LocationConstraintsDialogState
|
data object LocationServiceDisabled : LocationConstraintsDialogState
|
||||||
|
data object NotEnoughPowerLevel : LocationConstraintsDialogState
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||||||
fun LocationShareRow(
|
fun LocationShareRow(
|
||||||
item: LocationShareItem,
|
item: LocationShareItem,
|
||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
|
onStopClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
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) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CompoundIcons.ShareAndroid(),
|
imageVector = CompoundIcons.ShareAndroid(),
|
||||||
contentDescription = stringResource(CommonStrings.action_share),
|
contentDescription = stringResource(CommonStrings.action_share),
|
||||||
tint = ElementTheme.colors.iconPrimary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
|||||||
formattedTimestamp = "Shared 1 min ago",
|
formattedTimestamp = "Shared 1 min ago",
|
||||||
isLive = true,
|
isLive = true,
|
||||||
assetType = AssetType.SENDER,
|
assetType = AssetType.SENDER,
|
||||||
location = Location(0.0, 0.0)
|
location = Location(0.0, 0.0),
|
||||||
|
isOwnUser = true,
|
||||||
),
|
),
|
||||||
|
onStopClick = {},
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
)
|
)
|
||||||
LocationShareRow(
|
LocationShareRow(
|
||||||
@@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
|||||||
isLive = false,
|
isLive = false,
|
||||||
assetType = AssetType.PIN,
|
assetType = AssetType.PIN,
|
||||||
formattedTimestamp = "Shared 5 hours ago",
|
formattedTimestamp = "Shared 5 hours ago",
|
||||||
location = Location(0.0, 0.0)
|
location = Location(0.0, 0.0),
|
||||||
|
isOwnUser = false
|
||||||
),
|
),
|
||||||
|
onStopClick = {},
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
|
|||||||
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
||||||
import org.maplibre.compose.location.rememberNullLocationProvider
|
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||||
import org.maplibre.compose.location.rememberUserLocationState
|
import org.maplibre.compose.location.rememberUserLocationState
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserLocationPuck(
|
fun UserLocationPuck(
|
||||||
@@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
|
|||||||
rememberNullLocationProvider()
|
rememberNullLocationProvider()
|
||||||
} else {
|
} else {
|
||||||
rememberAndroidLocationProvider(
|
rememberAndroidLocationProvider(
|
||||||
updateInterval = 1.minutes,
|
updateInterval = 5.seconds,
|
||||||
desiredAccuracy = DesiredAccuracy.Balanced,
|
desiredAccuracy = DesiredAccuracy.High,
|
||||||
minDistanceMeters = 50f,
|
minDistanceMeters = 5f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return rememberUserLocationState(locationProvider)
|
return rememberUserLocationState(locationProvider)
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.di
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesTo
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingService
|
||||||
|
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
interface LocationBindings {
|
||||||
|
fun inject(service: LiveLocationSharingService)
|
||||||
|
}
|
||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import dev.zacsweers.metro.binding
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.BeaconId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
|
||||||
|
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||||
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.getAndUpdate
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
@ContributesBinding(SessionScope::class, binding = binding<ActiveLiveLocationShareManager>())
|
||||||
|
class DefaultActiveLiveLocationShareManager(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
private val coordinator: LiveLocationSharingCoordinator,
|
||||||
|
private val liveLocationStore: LiveLocationStore,
|
||||||
|
private val clock: SystemClock,
|
||||||
|
private val sessionObserver: SessionObserver,
|
||||||
|
) : ActiveLiveLocationShareManager, LiveLocationReceiver {
|
||||||
|
private val isSetup = AtomicBoolean(false)
|
||||||
|
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>()
|
||||||
|
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
|
||||||
|
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
|
||||||
|
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
|
||||||
|
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
|
||||||
|
|
||||||
|
override suspend fun setup() = withContext(NonCancellable) {
|
||||||
|
if (isSetup.compareAndSet(expectedValue = false, newValue = true)) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager setup manager.")
|
||||||
|
|
||||||
|
recoverPersistedShares()
|
||||||
|
|
||||||
|
matrixClient.ownBeaconInfoUpdates
|
||||||
|
.onEach { update ->
|
||||||
|
Timber.d("Received beaconInfoUpdate:$update")
|
||||||
|
// First cancel the local share in this room if any.
|
||||||
|
if (update.roomId in localSharingRoomIds.value) {
|
||||||
|
stopLocalShare(roomId = update.roomId)
|
||||||
|
}
|
||||||
|
syncedActiveShareIds.update {
|
||||||
|
if (update.isLive) {
|
||||||
|
it + update.beaconId
|
||||||
|
} else {
|
||||||
|
it - update.beaconId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(matrixClient.sessionCoroutineScope)
|
||||||
|
|
||||||
|
sessionObserver.addListener(sessionListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sessionListener: SessionListener = object : SessionListener {
|
||||||
|
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||||
|
if (matrixClient.sessionId.value == userId) {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = withContext(NonCancellable) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager starting share for room $roomId with duration ${duration.inWholeSeconds}s")
|
||||||
|
val room = cachedRooms.getOrPut(roomId) {
|
||||||
|
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
|
||||||
|
}
|
||||||
|
// Before starting a new location share, stop the current one if any is active.
|
||||||
|
room.stopLiveLocationShare()
|
||||||
|
|
||||||
|
room.startLiveLocationShare(duration.inWholeMilliseconds)
|
||||||
|
.onSuccess { beaconId ->
|
||||||
|
Timber.d("ActiveLiveLocationShareManager wait remote echo of $beaconId")
|
||||||
|
syncedActiveShareIds.first { beaconIds -> beaconIds.contains(beaconId) }
|
||||||
|
val expiresAt = Instant.fromEpochMilliseconds(clock.epochMillis() + duration.inWholeMilliseconds)
|
||||||
|
startLocalShare(roomId, expiresAt)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "ActiveLiveLocationShareManager failed to start share for room $roomId")
|
||||||
|
stopLocalShare(roomId)
|
||||||
|
}
|
||||||
|
.map { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopShare(roomId: RoomId): Result<Unit> = withContext(NonCancellable) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager stopping share for room $roomId")
|
||||||
|
val room = cachedRooms.getOrPut(roomId) {
|
||||||
|
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
|
||||||
|
}
|
||||||
|
room.stopLiveLocationShare()
|
||||||
|
.onSuccess {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager share stopped successfully for room $roomId")
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "ActiveLiveLocationShareManager failed to stop share for room $roomId")
|
||||||
|
}
|
||||||
|
.also {
|
||||||
|
stopLocalShare(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onLocationUpdate(location: Location) {
|
||||||
|
val activeSharesCount = localSharingRoomIds.value.size
|
||||||
|
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
|
||||||
|
localSharingRoomIds.value.forEach { roomId ->
|
||||||
|
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
|
||||||
|
sendLiveLocation(roomId, location)
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "ActiveLiveLocationShareManager failed to send location to room $roomId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
|
||||||
|
val room = cachedRooms.getOrPut(roomId) {
|
||||||
|
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
|
||||||
|
}
|
||||||
|
return room.sendLiveLocation(location.toGeoUri())
|
||||||
|
.recoverCatching { exception ->
|
||||||
|
when (exception) {
|
||||||
|
is LiveLocationException.NotLive -> {
|
||||||
|
stopLocalShare(roomId)
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
else -> throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun startLocalShare(roomId: RoomId, expiresAt: Instant) {
|
||||||
|
val wasEmpty = localSharingRoomIds.value.isEmpty()
|
||||||
|
Timber.d("ActiveLiveLocationShareManager share started successfully for room $roomId (wasEmpty=$wasEmpty)")
|
||||||
|
localSharingRoomIds.update { it + roomId }
|
||||||
|
liveLocationStore.setLiveLocationExpiry(roomId, expiresAt)
|
||||||
|
scheduleTimeout(roomId, expiresAt)
|
||||||
|
if (wasEmpty) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager registering with coordinator for session ${matrixClient.sessionId}")
|
||||||
|
coordinator.register(matrixClient.sessionId, this@DefaultActiveLiveLocationShareManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recoverPersistedShares() {
|
||||||
|
val now = Instant.fromEpochMilliseconds(clock.epochMillis())
|
||||||
|
liveLocationStore.getLiveLocationExpiries().forEach { (roomId, expiresAt) ->
|
||||||
|
if (expiresAt > now) {
|
||||||
|
// Only starts locally as the share is already started remotely
|
||||||
|
startLocalShare(roomId, expiresAt)
|
||||||
|
} else {
|
||||||
|
// Explicitly stop the share on the server.
|
||||||
|
stopShare(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
|
||||||
|
timeoutJobs.remove(roomId)?.cancel()
|
||||||
|
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
|
||||||
|
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
|
||||||
|
delay(delayMillis)
|
||||||
|
stopShare(roomId)
|
||||||
|
.onFailure { error ->
|
||||||
|
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stopLocalShare(roomId: RoomId) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
|
||||||
|
timeoutJobs.remove(roomId)?.cancel()
|
||||||
|
val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
|
||||||
|
cachedRooms.remove(roomId)?.close()
|
||||||
|
liveLocationStore.removeLiveLocationExpiry(roomId)
|
||||||
|
if (wasSharing && localSharingRoomIds.value.isEmpty()) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager unregistering from coordinator for session ${matrixClient.sessionId}")
|
||||||
|
coordinator.unregister(matrixClient.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun clear() {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager clear state")
|
||||||
|
sessionObserver.removeListener(sessionListener)
|
||||||
|
coordinator.unregister(matrixClient.sessionId)
|
||||||
|
liveLocationStore.clear()
|
||||||
|
for (room in cachedRooms.values) {
|
||||||
|
room.close()
|
||||||
|
timeoutJobs[room.roomId]?.cancel()
|
||||||
|
}
|
||||||
|
timeoutJobs.clear()
|
||||||
|
cachedRooms.clear()
|
||||||
|
localSharingRoomIds.value = emptySet()
|
||||||
|
syncedActiveShareIds.value = emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.libraries.androidutils.hash.hash
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR = "="
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
class LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory,
|
||||||
|
sessionId: SessionId,
|
||||||
|
) {
|
||||||
|
private val store = preferenceDataStoreFactory.create("location_${sessionId.value.hash().take(16)}")
|
||||||
|
private val acceptedLiveLocationDisclaimerKey = booleanPreferencesKey("live_location_disclaimer_accepted")
|
||||||
|
private val liveLocationExpiriesKey = stringSetPreferencesKey("live_location_expiries")
|
||||||
|
|
||||||
|
suspend fun hasAcceptedLiveLocationDisclaimer(): Boolean = runCatchingExceptions {
|
||||||
|
store.data.first()[acceptedLiveLocationDisclaimerKey] ?: false
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
suspend fun setAcceptedLiveLocationDisclaimer(): Result<Unit> = runCatchingExceptions {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[acceptedLiveLocationDisclaimerKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLiveLocationExpiries(): Map<RoomId, Instant> = runCatchingExceptions {
|
||||||
|
val serialized = store.data.first()[liveLocationExpiriesKey].orEmpty()
|
||||||
|
decodeLiveLocationExpiries(serialized)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Timber.e(error, "Failed to decode live location expiry payload")
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
|
||||||
|
suspend fun setLiveLocationExpiry(roomId: RoomId, expiresAt: Instant): Result<Unit> = runCatchingExceptions {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||||
|
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeLiveLocationExpiry(roomId: RoomId): Result<Unit> = runCatchingExceptions {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||||
|
val updated = current - roomId
|
||||||
|
if (updated.isEmpty()) {
|
||||||
|
prefs.remove(liveLocationExpiriesKey)
|
||||||
|
} else {
|
||||||
|
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeLiveLocationExpiries(serialized: Set<String>): Map<RoomId, Instant> {
|
||||||
|
return runCatchingExceptions {
|
||||||
|
serialized
|
||||||
|
.map { it.split(LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR) }
|
||||||
|
.associate { values ->
|
||||||
|
val roomId = RoomId(values[0])
|
||||||
|
val expiresAtMillis = values[1].toLong()
|
||||||
|
roomId to Instant.fromEpochMilliseconds(expiresAtMillis)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeLiveLocationExpiries(expiries: Map<RoomId, Instant>): Set<String> {
|
||||||
|
return expiries.entries.map { (roomId, expiresAt) ->
|
||||||
|
"${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}"
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clear() {
|
||||||
|
store.edit { prefs -> prefs.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live.notification
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.ChecksSdkIntAtLeast
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class LiveLocationSharingNotificationCreator(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "LIVE_LOCATION_SHARING"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createNotification(): Notification {
|
||||||
|
if (supportNotificationChannels()) {
|
||||||
|
ensureChannelExists()
|
||||||
|
}
|
||||||
|
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||||
|
.setContentTitle(context.getString(CommonStrings.live_location_sharing_foreground_service_title_android, buildMeta.applicationName))
|
||||||
|
.setContentText(context.getString(CommonStrings.live_location_sharing_foreground_service_message_android))
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun ensureChannelExists() {
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
notificationManager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(CommonStrings.live_location_sharing_foreground_service_channel_title_android)
|
||||||
|
.ifEmpty { "Live Location Sharing" },
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||||
|
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live.service
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
|
fun interface LiveLocationReceiver {
|
||||||
|
suspend fun onLocationUpdate(location: Location)
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.concurrent.atomics.AtomicLong
|
||||||
|
import kotlin.concurrent.atomics.AtomicReference
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
private val THROTTLE_WINDOW = 3.seconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class LiveLocationSharingCoordinator internal constructor(
|
||||||
|
private val startService: () -> Unit,
|
||||||
|
private val stopService: () -> Unit,
|
||||||
|
private val nowMillis: () -> Long,
|
||||||
|
) {
|
||||||
|
@Inject
|
||||||
|
constructor(@ApplicationContext context: Context, clock: SystemClock) : this(
|
||||||
|
startService = {
|
||||||
|
ContextCompat.startForegroundService(context, Intent(context, LiveLocationSharingService::class.java))
|
||||||
|
},
|
||||||
|
stopService = {
|
||||||
|
context.stopService(Intent(context, LiveLocationSharingService::class.java))
|
||||||
|
},
|
||||||
|
nowMillis = clock::epochMillis
|
||||||
|
)
|
||||||
|
|
||||||
|
private val receivers = ConcurrentHashMap<SessionId, LiveLocationReceiver>()
|
||||||
|
|
||||||
|
private val lastDispatchMillis = AtomicLong(0L)
|
||||||
|
private val lastKnownLocation = AtomicReference<Location?>(null)
|
||||||
|
|
||||||
|
suspend fun register(sessionId: SessionId, receiver: LiveLocationReceiver) {
|
||||||
|
val wasEmpty = receivers.isEmpty()
|
||||||
|
Timber.d("LiveLocationSharingCoordinator registering receiver for session $sessionId (wasEmpty=$wasEmpty)")
|
||||||
|
receivers[sessionId] = receiver
|
||||||
|
if (wasEmpty) {
|
||||||
|
Timber.d("LiveLocationSharingCoordinator starting service")
|
||||||
|
runCatchingExceptions(startService).onFailure {
|
||||||
|
Timber.e(it, "Failed to start live location sharing service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastKnownLocation.load()?.let {
|
||||||
|
dispatch(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(sessionId: SessionId) {
|
||||||
|
Timber.d("LiveLocationSharingCoordinator unregistering receiver for session $sessionId")
|
||||||
|
receivers.remove(sessionId)
|
||||||
|
if (receivers.isEmpty()) {
|
||||||
|
lastKnownLocation.store(null)
|
||||||
|
Timber.d("LiveLocationSharingCoordinator stopping service (no more receivers)")
|
||||||
|
runCatchingExceptions(stopService).onFailure {
|
||||||
|
Timber.e(it, "Failed to stop live location sharing service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dispatch(location: Location) {
|
||||||
|
val currentTimeMillis = nowMillis()
|
||||||
|
val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
|
||||||
|
if (millisSincePrevious < THROTTLE_WINDOW.inWholeMilliseconds) {
|
||||||
|
Timber.d("Received location before $THROTTLE_WINDOW, ignore.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastKnownLocation.store(location)
|
||||||
|
lastDispatchMillis.store(currentTimeMillis)
|
||||||
|
receivers.forEach { (sessionId, receiver) ->
|
||||||
|
Timber.d("Dispatch received location for session $sessionId ")
|
||||||
|
runCatchingExceptions {
|
||||||
|
receiver.onLocationUpdate(location)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to dispatch live location update for session $sessionId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.location.impl.di.LocationBindings
|
||||||
|
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.core.coroutine.childScope
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import org.maplibre.compose.location.AndroidLocationProvider
|
||||||
|
import org.maplibre.compose.location.DesiredAccuracy
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import io.element.android.features.location.api.Location as ApiLocation
|
||||||
|
|
||||||
|
private const val UPDATE_INTERVAL_IN_SECOND = 10
|
||||||
|
|
||||||
|
class LiveLocationSharingService : Service() {
|
||||||
|
@Inject lateinit var coordinator: LiveLocationSharingCoordinator
|
||||||
|
@Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
|
||||||
|
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||||
|
|
||||||
|
@Inject lateinit var appForegroundStateService: AppForegroundStateService
|
||||||
|
|
||||||
|
@AppCoroutineScope
|
||||||
|
@Inject lateinit var appCoroutineScope: CoroutineScope
|
||||||
|
private lateinit var coroutineScope: CoroutineScope
|
||||||
|
|
||||||
|
override fun onBind(p0: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Timber.d("LiveLocationSharingService onCreate")
|
||||||
|
runCatchingExceptions {
|
||||||
|
bindings<LocationBindings>().inject(this)
|
||||||
|
appForegroundStateService.updateIsSharingLiveLocation(true)
|
||||||
|
coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
|
||||||
|
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
|
||||||
|
Timber.d("LiveLocationSharingService starting foreground service with notificationId=$notificationId")
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
// service =
|
||||||
|
this,
|
||||||
|
// id =
|
||||||
|
notificationId,
|
||||||
|
// notification =
|
||||||
|
notificationCreator.createNotification(),
|
||||||
|
// foregroundServiceType =
|
||||||
|
FOREGROUND_SERVICE_TYPE_LOCATION,
|
||||||
|
)
|
||||||
|
startLocationUpdatesListener()
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to start live location sharing service")
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun startLocationUpdatesListener() {
|
||||||
|
Timber.d("LiveLocationSharingService listening to location updates")
|
||||||
|
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
|
||||||
|
.flatMapLatest { minDistanceMeters ->
|
||||||
|
val locationProvider = AndroidLocationProvider(
|
||||||
|
context = applicationContext,
|
||||||
|
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
|
||||||
|
minDistanceMeters = minDistanceMeters.toFloat(),
|
||||||
|
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||||
|
coroutineScope = coroutineScope
|
||||||
|
)
|
||||||
|
locationProvider.location
|
||||||
|
}
|
||||||
|
.filterNotNull()
|
||||||
|
.map { location ->
|
||||||
|
ApiLocation(
|
||||||
|
lat = location.position.latitude,
|
||||||
|
lon = location.position.longitude,
|
||||||
|
accuracy = location.accuracy.toFloat(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEach(coordinator::dispatch)
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Timber.d("LiveLocationSharingService onStartCommand startId=$startId")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Timber.d("LiveLocationSharingService onDestroy")
|
||||||
|
if (::coroutineScope.isInitialized) {
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
appForegroundStateService.updateIsSharingLiveLocation(false)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -17,7 +17,8 @@ sealed interface ShareLocationEvent {
|
|||||||
val isPinned: Boolean,
|
val isPinned: Boolean,
|
||||||
) : ShareLocationEvent
|
) : ShareLocationEvent
|
||||||
|
|
||||||
data object ShowLiveLocationDurationPicker : ShareLocationEvent
|
data object InitiateLiveLocationShare : ShareLocationEvent
|
||||||
|
data object AcceptLiveLocationDisclaimer : ShareLocationEvent
|
||||||
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
||||||
|
|
||||||
data object StartTrackingUserLocation : ShareLocationEvent
|
data object StartTrackingUserLocation : ShareLocationEvent
|
||||||
|
|||||||
+49
-13
@@ -21,17 +21,22 @@ import dev.zacsweers.metro.Assisted
|
|||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
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.LocationConstraintsCheck
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
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.actions.LocationActions
|
||||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
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.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.sendLiveLocationPermissions
|
||||||
import io.element.android.features.location.impl.common.toDialogState
|
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.features.messages.api.MessageComposerContext
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.extensions.flatMap
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
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.CreateTimelineParams
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
@@ -63,6 +69,8 @@ class ShareLocationPresenter(
|
|||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
private val durationFormatter: DurationFormatter,
|
private val durationFormatter: DurationFormatter,
|
||||||
|
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
|
private val liveLocationStore: LiveLocationStore,
|
||||||
) : Presenter<ShareLocationState> {
|
) : Presenter<ShareLocationState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
@@ -82,15 +90,39 @@ class ShareLocationPresenter(
|
|||||||
var dialogState: ShareLocationState.Dialog by remember {
|
var dialogState: ShareLocationState.Dialog by remember {
|
||||||
mutableStateOf(ShareLocationState.Dialog.None)
|
mutableStateOf(ShareLocationState.Dialog.None)
|
||||||
}
|
}
|
||||||
|
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
val currentUser by client.userProfile.collectAsState()
|
val currentUser by client.userProfile.collectAsState()
|
||||||
|
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
|
||||||
|
perms.sendLiveLocationPermissions()
|
||||||
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
fun checkLocationConstraints() {
|
fun checkLocationConstraints() {
|
||||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
// No need to check SendLiveLocationPermissions here
|
||||||
dialogState = Constraints(locationConstraints.toDialogState())
|
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
|
||||||
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
|
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() }
|
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
|
||||||
|
|
||||||
fun handleEvent(event: ShareLocationEvent) {
|
fun handleEvent(event: ShareLocationEvent) {
|
||||||
@@ -109,20 +141,23 @@ class ShareLocationPresenter(
|
|||||||
locationActions.openLocationSettings()
|
locationActions.openLocationSettings()
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
}
|
}
|
||||||
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
|
ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
|
||||||
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
|
dialogState = computeLiveLocationDialogState()
|
||||||
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
|
}
|
||||||
val durations = LIVE_LOCATION_DURATIONS.map {
|
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
|
||||||
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
liveLocationStore.setAcceptedLiveLocationDisclaimer()
|
||||||
|
.onSuccess {
|
||||||
|
dialogState = computeLiveLocationDialogState()
|
||||||
}
|
}
|
||||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
|
||||||
} else {
|
|
||||||
Constraints(constraintsResult.toDialogState())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
|
startLiveLocationAction.runUpdatingState {
|
||||||
|
liveLocationShareManager.startShare(
|
||||||
|
roomId = room.roomId,
|
||||||
|
duration = event.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ShareLocationEvent.RequestPermissions -> {
|
ShareLocationEvent.RequestPermissions -> {
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
@@ -138,6 +173,7 @@ class ShareLocationPresenter(
|
|||||||
hasLocationPermission = permissionsState.isAnyGranted,
|
hasLocationPermission = permissionsState.isAnyGranted,
|
||||||
canShareLiveLocation = isLiveLocationSharingEnabled,
|
canShareLiveLocation = isLiveLocationSharingEnabled,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
|
startLiveLocationAction = startLiveLocationAction.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -9,6 +9,7 @@
|
|||||||
package io.element.android.features.location.impl.share
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
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 io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@@ -19,11 +20,13 @@ data class ShareLocationState(
|
|||||||
val hasLocationPermission: Boolean,
|
val hasLocationPermission: Boolean,
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val canShareLiveLocation: Boolean,
|
val canShareLiveLocation: Boolean,
|
||||||
|
val startLiveLocationAction: AsyncAction<Unit>,
|
||||||
val eventSink: (ShareLocationEvent) -> Unit,
|
val eventSink: (ShareLocationEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data object None : Dialog
|
data object None : Dialog
|
||||||
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
||||||
|
data object LiveLocationDisclaimer : Dialog
|
||||||
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
|
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
|
|||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
|
|||||||
trackUserPosition = true,
|
trackUserPosition = true,
|
||||||
hasLocationPermission = 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(
|
aShareLocationState(
|
||||||
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
@@ -73,6 +86,7 @@ fun aShareLocationState(
|
|||||||
hasLocationPermission: Boolean = false,
|
hasLocationPermission: Boolean = false,
|
||||||
canShareLiveLocation: Boolean = false,
|
canShareLiveLocation: Boolean = false,
|
||||||
appName: String = APP_NAME,
|
appName: String = APP_NAME,
|
||||||
|
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (ShareLocationEvent) -> Unit = {},
|
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||||
): ShareLocationState {
|
): ShareLocationState {
|
||||||
return ShareLocationState(
|
return ShareLocationState(
|
||||||
@@ -82,6 +96,7 @@ fun aShareLocationState(
|
|||||||
hasLocationPermission = hasLocationPermission,
|
hasLocationPermission = hasLocationPermission,
|
||||||
canShareLiveLocation = canShareLiveLocation,
|
canShareLiveLocation = canShareLiveLocation,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
|
startLiveLocationAction = startLiveLocationAction,
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-7
@@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
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.MapBottomSheetScaffold
|
||||||
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
||||||
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
|
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.LocationPin
|
||||||
import io.element.android.libraries.designsystem.components.PinVariant
|
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.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
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.dialogs.ListDialog
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
||||||
@@ -74,7 +78,6 @@ fun ShareLocationView(
|
|||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
when (val dialogState = state.dialogState) {
|
when (val dialogState = state.dialogState) {
|
||||||
ShareLocationState.Dialog.None -> Unit
|
ShareLocationState.Dialog.None -> Unit
|
||||||
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
||||||
@@ -85,12 +88,17 @@ fun ShareLocationView(
|
|||||||
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
||||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
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(
|
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
|
||||||
durations = dialogState.durations,
|
durations = dialogState.durations,
|
||||||
onSelectDuration = { duration ->
|
onSelectDuration = { duration ->
|
||||||
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
|
state.eventSink(StartLiveLocationShare(duration))
|
||||||
context.toast("Not implemented yet!")
|
|
||||||
navigateUp()
|
|
||||||
},
|
},
|
||||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||||
)
|
)
|
||||||
@@ -160,10 +168,46 @@ fun ShareLocationView(
|
|||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(all = 16.dp),
|
.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
|
@Composable
|
||||||
private fun BottomSheetContent(
|
private fun BottomSheetContent(
|
||||||
cameraState: CameraState,
|
cameraState: CameraState,
|
||||||
@@ -202,7 +246,7 @@ private fun BottomSheetContent(
|
|||||||
}
|
}
|
||||||
if (state.canShareLiveLocation) {
|
if (state.canShareLiveLocation) {
|
||||||
ShareLiveLocationItem {
|
ShareLiveLocationItem {
|
||||||
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
|||||||
data object RequestPermissions : ShowLocationEvent
|
data object RequestPermissions : ShowLocationEvent
|
||||||
data object OpenAppSettings : ShowLocationEvent
|
data object OpenAppSettings : ShowLocationEvent
|
||||||
data object OpenLocationSettings : ShowLocationEvent
|
data object OpenLocationSettings : ShowLocationEvent
|
||||||
|
data object StopLocationSharing : ShowLocationEvent
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -15,14 +15,17 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.ShowLocationMode
|
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.LocationConstraintsCheck
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
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.actions.LocationActions
|
||||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
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.PermissionsEvents
|
||||||
@@ -45,6 +48,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
|
|||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class ShowLocationPresenter(
|
class ShowLocationPresenter(
|
||||||
@@ -55,6 +59,7 @@ class ShowLocationPresenter(
|
|||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
private val stringProvider: StringProvider,
|
private val stringProvider: StringProvider,
|
||||||
private val joinedRoom: JoinedRoom,
|
private val joinedRoom: JoinedRoom,
|
||||||
|
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
) : Presenter<ShowLocationState> {
|
) : Presenter<ShowLocationState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
@@ -65,6 +70,7 @@ class ShowLocationPresenter(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ShowLocationState {
|
override fun present(): ShowLocationState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||||
var isTrackMyLocation by remember { mutableStateOf(false) }
|
var isTrackMyLocation by remember { mutableStateOf(false) }
|
||||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||||
@@ -85,7 +91,7 @@ class ShowLocationPresenter(
|
|||||||
}
|
}
|
||||||
is ShowLocationEvent.TrackMyLocation -> {
|
is ShowLocationEvent.TrackMyLocation -> {
|
||||||
if (event.enabled) {
|
if (event.enabled) {
|
||||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
||||||
dialogState = locationConstraints.toDialogState()
|
dialogState = locationConstraints.toDialogState()
|
||||||
} else {
|
} else {
|
||||||
@@ -102,6 +108,9 @@ class ShowLocationPresenter(
|
|||||||
dialogState = LocationConstraintsDialogState.None
|
dialogState = LocationConstraintsDialogState.None
|
||||||
}
|
}
|
||||||
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
|
ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
|
||||||
|
liveLocationShareManager.stopShare(joinedRoom.roomId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +136,7 @@ class ShowLocationPresenter(
|
|||||||
location = mode.location,
|
location = mode.location,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
assetType = mode.assetType,
|
assetType = mode.assetType,
|
||||||
|
isOwnUser = mode.senderId == joinedRoom.sessionId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -163,6 +173,7 @@ class ShowLocationPresenter(
|
|||||||
location = location,
|
location = location,
|
||||||
isLive = true,
|
isLive = true,
|
||||||
assetType = lastLocation.assetType,
|
assetType = lastLocation.assetType,
|
||||||
|
isOwnUser = share.userId == joinedRoom.sessionId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toImmutableList()
|
.toImmutableList()
|
||||||
|
|||||||
+4
-1
@@ -38,7 +38,10 @@ data class LocationShareItem(
|
|||||||
val location: Location,
|
val location: Location,
|
||||||
val isLive: Boolean,
|
val isLive: Boolean,
|
||||||
val assetType: AssetType?,
|
val assetType: AssetType?,
|
||||||
)
|
val isOwnUser: Boolean
|
||||||
|
) {
|
||||||
|
val canStopSharing = isLive && isOwnUser
|
||||||
|
}
|
||||||
|
|
||||||
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
||||||
val pinVariant = if (assetType == AssetType.PIN) {
|
val pinVariant = if (assetType == AssetType.PIN) {
|
||||||
|
|||||||
+2
@@ -81,6 +81,7 @@ fun aLocationShareItem(
|
|||||||
assetType: AssetType? = null,
|
assetType: AssetType? = null,
|
||||||
formattedTimestamp: String = "Shared 1 min ago",
|
formattedTimestamp: String = "Shared 1 min ago",
|
||||||
location: Location = Location(1.23, 2.34, 4f),
|
location: Location = Location(1.23, 2.34, 4f),
|
||||||
|
isOwnUser: Boolean = false,
|
||||||
) = LocationShareItem(
|
) = LocationShareItem(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
@@ -89,4 +90,5 @@ fun aLocationShareItem(
|
|||||||
location = location,
|
location = location,
|
||||||
isLive = isLive,
|
isLive = isLive,
|
||||||
assetType = assetType,
|
assetType = assetType,
|
||||||
|
isOwnUser = isOwnUser,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -147,6 +147,7 @@ fun ShowLocationView(
|
|||||||
LocationShareRow(
|
LocationShareRow(
|
||||||
item = locationShare,
|
item = locationShare,
|
||||||
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||||
|
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||||
val position = CameraPosition(
|
val position = CameraPosition(
|
||||||
|
|||||||
+17
-5
@@ -21,7 +21,7 @@ class LocationConstraintsCheckTest {
|
|||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ class LocationConstraintsCheckTest {
|
|||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ class LocationConstraintsCheckTest {
|
|||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ class LocationConstraintsCheckTest {
|
|||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
||||||
}
|
}
|
||||||
@@ -71,8 +71,20 @@ class LocationConstraintsCheckTest {
|
|||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns NotEnoughPowerLevel when send permissions are not granted`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.DEFAULT)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+488
@@ -0,0 +1,488 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
|
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DefaultActiveLiveLocationShareManagerTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `starting the first share starts the coordinator service after the beacon echo and adds an active share`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder
|
||||||
|
)
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply { givenGetRoomResult(A_ROOM_ID, room) },
|
||||||
|
coordinator = coordinator,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 123L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val result = async { manager.startShare(A_ROOM_ID, 60.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
|
||||||
|
assertThat(result.await().isSuccess).isTrue()
|
||||||
|
assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stopping the last share stops the coordinator service`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder
|
||||||
|
)
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply { givenGetRoomResult(A_ROOM_ID, room) },
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
val result = manager.stopShare(A_ROOM_ID)
|
||||||
|
|
||||||
|
assertThat(result.isSuccess).isTrue()
|
||||||
|
assertThat(manager.sharingRoomIds.value).isEmpty()
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two managers with the same room id keep isolated state per session`() = runTest {
|
||||||
|
val coordinator = createCoordinator()
|
||||||
|
val beaconInfoUpdatesOne = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val beaconInfoUpdatesTwo = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val managerOne = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdatesOne,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
val managerTwo = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID_2,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdatesTwo,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { managerOne.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdatesOne.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
assertThat(managerOne.sharingRoomIds.value).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(managerTwo.sharingRoomIds.value).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start share persists room expiry after beacon echo`() = runTest {
|
||||||
|
val liveLocationStore = createLiveLocationStore()
|
||||||
|
val coordinator = createCoordinator()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 123L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val result = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
|
||||||
|
assertThat(result.await().isSuccess).isTrue()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).containsKey(A_ROOM_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stop share removes persisted expiry`() = runTest {
|
||||||
|
val liveLocationStore = createLiveLocationStore()
|
||||||
|
val coordinator = createCoordinator()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
manager.stopShare(A_ROOM_ID)
|
||||||
|
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup restores unexpired stored share and registers coordinator`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val liveLocationStore = createLiveLocationStore().apply {
|
||||||
|
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
|
||||||
|
}
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom())
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder,
|
||||||
|
),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 1_000L),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup remotely stops expired stored share and removes it from store`() = runTest {
|
||||||
|
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val liveLocationStore = createLiveLocationStore().apply {
|
||||||
|
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
}
|
||||||
|
createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 5_000L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assert(stopLiveLocationShareResult).isCalledOnce()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stop share closes loaded room and removes persisted expiry when room is not tracked`() = runTest {
|
||||||
|
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val room = FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult)
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
)
|
||||||
|
liveLocationStore.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
|
||||||
|
|
||||||
|
val result = manager.stopShare(A_ROOM_ID)
|
||||||
|
|
||||||
|
assertThat(result.isSuccess).isTrue()
|
||||||
|
assert(stopLiveLocationShareResult).isCalledOnce()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
room.baseRoom.assertDestroyed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `share is automatically stopped when timeout elapses`() = runTest {
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = stopLiveLocationShareResult
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 123L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
manager.sharingRoomIds.test {
|
||||||
|
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
assert(stopLiveLocationShareResult).isCalledExactly(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restored share is automatically stopped when remaining timeout elapses`() = runTest {
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore().apply {
|
||||||
|
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(6_000L))
|
||||||
|
}
|
||||||
|
val stopLiveLocationShareLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
stopLiveLocationShareResult = stopLiveLocationShareLambda
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 1_000L),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.sharingRoomIds.test {
|
||||||
|
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
assert(stopLiveLocationShareLambda).isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session deleted clears local state`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||||
|
val sessionObserver = FakeSessionObserver()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder,
|
||||||
|
),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
sessionObserver = sessionObserver,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val firstStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(firstStart.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
sessionObserver.onSessionDeleted(A_SESSION_ID.value)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(manager.sharingRoomIds.value).isEmpty()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isCalledOnce()
|
||||||
|
|
||||||
|
val secondStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(secondStart.isCompleted).isFalse()
|
||||||
|
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(secondStart.await().isSuccess).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createManager(
|
||||||
|
client: FakeMatrixClient = FakeMatrixClient(sessionId = A_SESSION_ID),
|
||||||
|
coordinator: LiveLocationSharingCoordinator = createCoordinator(),
|
||||||
|
liveLocationStore: LiveLocationStore = createLiveLocationStore(),
|
||||||
|
clock: SystemClock = FakeSystemClock(),
|
||||||
|
sessionObserver: SessionObserver = FakeSessionObserver(),
|
||||||
|
): DefaultActiveLiveLocationShareManager {
|
||||||
|
return DefaultActiveLiveLocationShareManager(
|
||||||
|
matrixClient = client,
|
||||||
|
coordinator = coordinator,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = clock,
|
||||||
|
sessionObserver = sessionObserver,
|
||||||
|
).apply {
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCoordinator(
|
||||||
|
startService: () -> Unit = {},
|
||||||
|
stopService: () -> Unit = {},
|
||||||
|
nowMillis: () -> Long = { 0L },
|
||||||
|
): LiveLocationSharingCoordinator {
|
||||||
|
return LiveLocationSharingCoordinator(
|
||||||
|
startService = startService,
|
||||||
|
stopService = stopService,
|
||||||
|
nowMillis = nowMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createLiveLocationStore(
|
||||||
|
sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
): LiveLocationStore {
|
||||||
|
return LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createInMemoryLiveLocationStore(
|
||||||
|
sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
|
||||||
|
): LiveLocationStore {
|
||||||
|
val preferenceDataStoreFactory = object : PreferenceDataStoreFactory {
|
||||||
|
override fun create(name: String): DataStore<Preferences> {
|
||||||
|
var preferences: Preferences = emptyPreferences()
|
||||||
|
return object : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences>
|
||||||
|
get() = flowOf(preferences)
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||||
|
preferences = transform(preferences)
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createLiveLocationStore(
|
||||||
|
sessionId = sessionId,
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.live
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LiveLocationSharingCoordinatorTest {
|
||||||
|
@Test
|
||||||
|
fun `first registration starts the service and last unregister stops it`() = runTest {
|
||||||
|
var startCount = 0
|
||||||
|
var stopCount = 0
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { startCount++ },
|
||||||
|
stopService = { stopCount++ },
|
||||||
|
nowMillis = { 0L },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID, LiveLocationReceiver { })
|
||||||
|
coordinator.unregister(A_SESSION_ID)
|
||||||
|
|
||||||
|
assertThat(startCount).isEqualTo(1)
|
||||||
|
assertThat(stopCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch isolates receiver failures and still reaches later receivers`() = runTest {
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { 4_000L },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { error("boom") }
|
||||||
|
coordinator.register(A_SESSION_ID_2) { location -> delivered += location }
|
||||||
|
coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch delivers first location immediately`() = runTest {
|
||||||
|
var nowMillis = 4_000L
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { nowMillis },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { location -> delivered += location }
|
||||||
|
|
||||||
|
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
|
||||||
|
|
||||||
|
coordinator.dispatch(firstLocation)
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(firstLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch drops updates inside the throttle window`() = runTest {
|
||||||
|
var nowMillis = 4_000L
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { nowMillis },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { location -> delivered += location }
|
||||||
|
|
||||||
|
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
|
||||||
|
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
|
||||||
|
|
||||||
|
coordinator.dispatch(firstLocation)
|
||||||
|
nowMillis += 500
|
||||||
|
coordinator.dispatch(secondLocation)
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(firstLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch delivers next update after the throttle window elapses`() = runTest {
|
||||||
|
var nowMillis = 4_000L
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { nowMillis },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { location -> delivered += location }
|
||||||
|
|
||||||
|
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
|
||||||
|
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
|
||||||
|
|
||||||
|
coordinator.dispatch(firstLocation)
|
||||||
|
nowMillis += 3_000
|
||||||
|
coordinator.dispatch(secondLocation)
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -13,6 +13,8 @@ import com.bumble.appyx.core.modality.BuildContext
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
|
import io.element.android.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.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
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.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.node.TestParentNode
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -30,16 +34,17 @@ class DefaultShareLocationEntryPointTest {
|
|||||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test node builder`() {
|
fun `test node builder`() = runTest {
|
||||||
val entryPoint = DefaultShareLocationEntryPoint()
|
val entryPoint = DefaultShareLocationEntryPoint()
|
||||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
ShareLocationNode(
|
ShareLocationNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenterFactory = { timelineMode: Timeline.Mode ->
|
presenterFactory = { timelineMode: Timeline.Mode ->
|
||||||
ShareLocationPresenter(
|
ShareLocationPresenter(
|
||||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||||
room = FakeJoinedRoom(),
|
room = room,
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
messageComposerContext = FakeMessageComposerContext(),
|
messageComposerContext = FakeMessageComposerContext(),
|
||||||
@@ -48,6 +53,11 @@ class DefaultShareLocationEntryPointTest {
|
|||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
client = FakeMatrixClient(),
|
client = FakeMatrixClient(),
|
||||||
durationFormatter = FakeDurationFormatter(),
|
durationFormatter = FakeDurationFormatter(),
|
||||||
|
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
|
liveLocationStore = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
sessionId = room.sessionId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
|||||||
+242
-5
@@ -10,6 +10,9 @@
|
|||||||
|
|
||||||
package io.element.android.features.location.impl.share
|
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.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
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.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
import io.element.android.features.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.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.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.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.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.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.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
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.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
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.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
class ShareLocationPresenterTest {
|
class ShareLocationPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
@@ -59,9 +80,11 @@ class ShareLocationPresenterTest {
|
|||||||
|
|
||||||
private val durationFormatter = FakeDurationFormatter()
|
private val durationFormatter = FakeDurationFormatter()
|
||||||
|
|
||||||
private fun createShareLocationPresenter(
|
private fun TestScope.createShareLocationPresenter(
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||||
locationActions: FakeLocationActions = fakeLocationActions,
|
locationActions: FakeLocationActions = fakeLocationActions,
|
||||||
|
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
|
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||||
): ShareLocationPresenter = ShareLocationPresenter(
|
): ShareLocationPresenter = ShareLocationPresenter(
|
||||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||||
room = joinedRoom,
|
room = joinedRoom,
|
||||||
@@ -73,6 +96,8 @@ class ShareLocationPresenterTest {
|
|||||||
featureFlagService = fakeFeatureFlagService,
|
featureFlagService = fakeFeatureFlagService,
|
||||||
client = fakeMatrixClient,
|
client = fakeMatrixClient,
|
||||||
durationFormatter = durationFormatter,
|
durationFormatter = durationFormatter,
|
||||||
|
liveLocationShareManager = liveLocationShareManager,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -296,7 +321,15 @@ class ShareLocationPresenterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
|
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(
|
fakePermissionsPresenter.givenState(
|
||||||
aPermissionsState(
|
aPermissionsState(
|
||||||
permissions = PermissionsState.Permissions.AllGranted,
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
@@ -307,7 +340,7 @@ class ShareLocationPresenterTest {
|
|||||||
shareLocationPresenter.test {
|
shareLocationPresenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
val durationDialogState = awaitItem()
|
val durationDialogState = awaitItem()
|
||||||
|
|
||||||
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
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
|
@Test
|
||||||
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
|
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(
|
fakePermissionsPresenter.givenState(
|
||||||
aPermissionsState(
|
aPermissionsState(
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
@@ -332,7 +511,7 @@ class ShareLocationPresenterTest {
|
|||||||
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
||||||
val dismissedState = awaitItem()
|
val dismissedState = awaitItem()
|
||||||
|
|
||||||
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
val constraintDialogState = awaitItem()
|
val constraintDialogState = awaitItem()
|
||||||
|
|
||||||
assertThat(constraintDialogState.dialogState).isEqualTo(
|
assertThat(constraintDialogState.dialogState).isEqualTo(
|
||||||
@@ -447,4 +626,62 @@ class ShareLocationPresenterTest {
|
|||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `StartLiveLocationShare event calls manager startShare`() = runTest {
|
||||||
|
val startShareLambda = lambdaRecorder { _: RoomId, _: Duration -> Result.success(Unit) }
|
||||||
|
val manager = FakeActiveLiveLocationShareManager(
|
||||||
|
startShareLambda = startShareLambda,
|
||||||
|
)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(liveLocationShareManager = manager)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assert(startShareLambda).isCalledOnce().with(
|
||||||
|
value(A_ROOM_ID),
|
||||||
|
value(1.hours)
|
||||||
|
)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createLiveLocationStore(
|
||||||
|
sessionId: SessionId = A_SESSION_ID,
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
): LiveLocationStore {
|
||||||
|
return LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFailingLiveLocationStore(sessionId: SessionId = A_SESSION_ID): LiveLocationStore {
|
||||||
|
val failingPreferenceDataStoreFactory = object : PreferenceDataStoreFactory {
|
||||||
|
override fun create(name: String): DataStore<Preferences> = object : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences> = flowOf(emptyPreferences())
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||||
|
error("Failed to update preferences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createLiveLocationStore(
|
||||||
|
sessionId = sessionId,
|
||||||
|
preferenceDataStoreFactory = failingPreferenceDataStoreFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun grantedSendLiveLocationPermissions(): FakeRoomPermissions = FakeRoomPermissions(
|
||||||
|
canSendState = { it is StateEventType.BeaconInfo },
|
||||||
|
canSendMessage = { it is MessageEventType.Beacon }
|
||||||
|
)
|
||||||
|
|||||||
+32
@@ -143,6 +143,38 @@ class ShareLocationViewTest {
|
|||||||
clickOn(CommonStrings.action_cancel)
|
clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
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(
|
private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(
|
||||||
|
|||||||
+4
-1
@@ -16,6 +16,7 @@ import io.element.android.features.location.api.ShowLocationEntryPoint
|
|||||||
import io.element.android.features.location.api.ShowLocationMode
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
|
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
@@ -34,6 +35,7 @@ class DefaultShowLocationEntryPointTest {
|
|||||||
fun `test node builder`() {
|
fun `test node builder`() {
|
||||||
val entryPoint = DefaultShowLocationEntryPoint()
|
val entryPoint = DefaultShowLocationEntryPoint()
|
||||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
val joinedRoom = FakeJoinedRoom()
|
||||||
ShowLocationNode(
|
ShowLocationNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
@@ -45,7 +47,8 @@ class DefaultShowLocationEntryPointTest {
|
|||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
dateFormatter = FakeDateFormatter(),
|
dateFormatter = FakeDateFormatter(),
|
||||||
stringProvider = FakeStringProvider(),
|
stringProvider = FakeStringProvider(),
|
||||||
joinedRoom = FakeJoinedRoom(),
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
|||||||
+1
-13
@@ -9,7 +9,7 @@ package io.element.android.features.location.impl.show
|
|||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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
|
import org.junit.Test
|
||||||
|
|
||||||
class LiveLocationShareComparatorTest {
|
class LiveLocationShareComparatorTest {
|
||||||
@@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
|
|||||||
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-22
@@ -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.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
import io.element.android.features.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.dateformatter.test.FakeDateFormatter
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
|
||||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
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.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
@@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
|
|||||||
),
|
),
|
||||||
locationActions: FakeLocationActions = fakeLocationActions,
|
locationActions: FakeLocationActions = fakeLocationActions,
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||||
|
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
) = ShowLocationPresenter(
|
) = ShowLocationPresenter(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||||
@@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
|
|||||||
dateFormatter = fakeDateFormatter,
|
dateFormatter = fakeDateFormatter,
|
||||||
stringProvider = FakeStringProvider(),
|
stringProvider = FakeStringProvider(),
|
||||||
joinedRoom = joinedRoom,
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationShareManager = liveLocationShareManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
val presenter = createShowLocationPresenter()
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.test {
|
presenter.test {
|
||||||
// Skip initial state
|
// Skip initial state
|
||||||
val initialState = awaitItem()
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.store
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class LiveLocationStoreTest {
|
||||||
|
private val preferenceDataStoreFactory = FakePreferenceDataStoreFactory()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disclaimer defaults to false`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(store.hasAcceptedLiveLocationDisclaimer()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disclaimer acceptance is isolated per session`() = runTest {
|
||||||
|
val firstStore = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
val secondStore = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = SessionId("@other:server"),
|
||||||
|
)
|
||||||
|
|
||||||
|
firstStore.setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||||
|
|
||||||
|
assertThat(firstStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
|
||||||
|
assertThat(secondStore.hasAcceptedLiveLocationDisclaimer()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `can persist and read expiry per room`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries())
|
||||||
|
.containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing one expiry leaves others untouched`() = runTest {
|
||||||
|
val otherRoomId = RoomId("!other:server")
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
|
||||||
|
store.setLiveLocationExpiry(otherRoomId, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
|
||||||
|
store.removeLiveLocationExpiry(A_ROOM_ID).getOrThrow()
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries())
|
||||||
|
.containsExactly(otherRoomId, Instant.fromEpochMilliseconds(2_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting expiry twice replaces the existing room value`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries())
|
||||||
|
.containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `malformed expiry payload returns empty map`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = createMalformedExpiryPreferenceDataStoreFactory(),
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMalformedExpiryPreferenceDataStoreFactory(): PreferenceDataStoreFactory {
|
||||||
|
return object : PreferenceDataStoreFactory {
|
||||||
|
override fun create(name: String): DataStore<Preferences> {
|
||||||
|
var preferences: Preferences = mutablePreferencesOf(
|
||||||
|
stringPreferencesKey("live_location_expiries") to "not valid"
|
||||||
|
)
|
||||||
|
return object : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences>
|
||||||
|
get() = flowOf(preferences)
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||||
|
preferences = transform(preferences)
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.test
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class FakeActiveLiveLocationShareManager(
|
||||||
|
val setupLambda: () -> Unit = { lambdaError() },
|
||||||
|
val startShareLambda: (roomId: RoomId, duration: Duration) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
|
val stopShareLambda: (roomId: RoomId) -> Result<Unit> = { _ -> lambdaError() },
|
||||||
|
) : ActiveLiveLocationShareManager {
|
||||||
|
private val _sharingRoomIds = MutableStateFlow(emptySet<RoomId>())
|
||||||
|
override val sharingRoomIds: StateFlow<Set<RoomId>> = _sharingRoomIds
|
||||||
|
|
||||||
|
override suspend fun setup() {
|
||||||
|
setupLambda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = simulateLongTask {
|
||||||
|
startShareLambda(roomId, duration).onSuccess {
|
||||||
|
_sharingRoomIds.update {
|
||||||
|
it + roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopShare(roomId: RoomId): Result<Unit> = simulateLongTask {
|
||||||
|
stopShareLambda(roomId).onSuccess {
|
||||||
|
_sharingRoomIds.update {
|
||||||
|
it - roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -18,6 +18,8 @@ sealed interface MessagesEvent {
|
|||||||
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
|
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
|
||||||
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
|
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
|
||||||
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
|
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
|
||||||
|
data object StopLiveLocationShare : MessagesEvent
|
||||||
|
data object ShowLiveLocationShare : MessagesEvent
|
||||||
data object MarkAsFullyReadAndExit : MessagesEvent
|
data object MarkAsFullyReadAndExit : MessagesEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -278,6 +278,10 @@ class MessagesFlowNode(
|
|||||||
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
|
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) {
|
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
|
||||||
val callData = CallData(
|
val callData = CallData(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -513,6 +517,10 @@ class MessagesFlowNode(
|
|||||||
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
|
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) {
|
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
|
||||||
val callData = CallData(
|
val callData = CallData(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
|||||||
+1
@@ -26,5 +26,6 @@ interface MessagesNavigator {
|
|||||||
fun navigateToMember(userId: UserId)
|
fun navigateToMember(userId: UserId)
|
||||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||||
fun navigateToDeveloperSettings()
|
fun navigateToDeveloperSettings()
|
||||||
|
fun navigateToCurrentLiveLocation()
|
||||||
fun close()
|
fun close()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -127,6 +127,7 @@ class MessagesNode(
|
|||||||
fun navigateToSendLocation()
|
fun navigateToSendLocation()
|
||||||
fun navigateToCreatePoll()
|
fun navigateToCreatePoll()
|
||||||
fun navigateToEditPoll(eventId: EventId)
|
fun navigateToEditPoll(eventId: EventId)
|
||||||
|
fun navigateToCurrentLiveLocation()
|
||||||
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
||||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||||
fun navigateToRoomDetails()
|
fun navigateToRoomDetails()
|
||||||
@@ -239,6 +240,10 @@ class MessagesNode(
|
|||||||
callback.navigateToDeveloperSettings()
|
callback.navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToCurrentLiveLocation() {
|
||||||
|
callback.navigateToCurrentLiveLocation()
|
||||||
|
}
|
||||||
|
|
||||||
private fun displaySameRoomToast() {
|
private fun displaySameRoomToast() {
|
||||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -27,6 +27,8 @@ import dev.zacsweers.metro.AssistedFactory
|
|||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||||
import io.element.android.appconfig.MessageComposerConfig
|
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.api.timeline.HtmlConverterProvider
|
||||||
import io.element.android.features.messages.impl.MessagesState.Threads
|
import io.element.android.features.messages.impl.MessagesState.Threads
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
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.history.RoomHistoryVisibility
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
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.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.api.timeline.item.event.EventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
@@ -126,6 +129,7 @@ class MessagesPresenter(
|
|||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val addRecentEmoji: AddRecentEmoji,
|
private val addRecentEmoji: AddRecentEmoji,
|
||||||
private val markAsFullyRead: MarkAsFullyRead,
|
private val markAsFullyRead: MarkAsFullyRead,
|
||||||
|
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
@@ -172,6 +176,7 @@ class MessagesPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
|
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 ->
|
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||||
perms.userEventPermissions()
|
perms.userEventPermissions()
|
||||||
@@ -260,6 +265,18 @@ class MessagesPresenter(
|
|||||||
is MessagesEvent.OnUserClicked -> {
|
is MessagesEvent.OnUserClicked -> {
|
||||||
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
|
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)) {
|
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
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
|
// TODO calculate this properly based on the thread list and the read state of each thread
|
||||||
hasUnreadThreads = false,
|
hasUnreadThreads = false,
|
||||||
),
|
),
|
||||||
|
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -58,6 +58,7 @@ data class MessagesState(
|
|||||||
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
||||||
val successorRoom: SuccessorRoom?,
|
val successorRoom: SuccessorRoom?,
|
||||||
val threads: Threads,
|
val threads: Threads,
|
||||||
|
val showLiveLocationShareBanner: Boolean,
|
||||||
val eventSink: (MessagesEvent) -> Unit
|
val eventSink: (MessagesEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val isTombstoned = successorRoom != null
|
val isTombstoned = successorRoom != null
|
||||||
|
|||||||
+3
@@ -80,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
|||||||
currentPinnedMessageIndex = 0,
|
currentPinnedMessageIndex = 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
|
||||||
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
|
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
|
||||||
aMessagesState(
|
aMessagesState(
|
||||||
timelineState = aTimelineState(
|
timelineState = aTimelineState(
|
||||||
@@ -127,6 +128,7 @@ fun aMessagesState(
|
|||||||
hasThreads = false,
|
hasThreads = false,
|
||||||
hasUnreadThreads = false,
|
hasUnreadThreads = false,
|
||||||
),
|
),
|
||||||
|
isCurrentlySharingLiveLocationInRoom: Boolean = false,
|
||||||
eventSink: (MessagesEvent) -> Unit = {},
|
eventSink: (MessagesEvent) -> Unit = {},
|
||||||
) = MessagesState(
|
) = MessagesState(
|
||||||
roomId = RoomId("!id:domain"),
|
roomId = RoomId("!id:domain"),
|
||||||
@@ -156,6 +158,7 @@ fun aMessagesState(
|
|||||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||||
successorRoom = successorRoom,
|
successorRoom = successorRoom,
|
||||||
threads = threads,
|
threads = threads,
|
||||||
|
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+46
-36
@@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
@@ -205,15 +206,15 @@ fun MessagesView(
|
|||||||
val expandableState = rememberExpandableBottomSheetLayoutState()
|
val expandableState = rememberExpandableBottomSheetLayoutState()
|
||||||
ExpandableBottomSheetLayout(
|
ExpandableBottomSheetLayout(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.systemBarsPadding()
|
.systemBarsPadding()
|
||||||
.onSizeChanged { size ->
|
.onSizeChanged { size ->
|
||||||
// Let the composer takes at max half of the available height.
|
// Let the composer takes at max half of the available height.
|
||||||
// The value will be different if the soft keyboard is displayed
|
// The value will be different if the soft keyboard is displayed
|
||||||
// or not.
|
// or not.
|
||||||
maxComposerHeightPx = (size.height * 0.5f).toInt()
|
maxComposerHeightPx = (size.height * 0.5f).toInt()
|
||||||
},
|
},
|
||||||
content = {
|
content = {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
@@ -250,8 +251,8 @@ fun MessagesView(
|
|||||||
content = { padding ->
|
content = { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
) {
|
) {
|
||||||
MessagesViewContent(
|
MessagesViewContent(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -288,10 +289,10 @@ fun MessagesView(
|
|||||||
|
|
||||||
SuggestionsPickerView(
|
SuggestionsPickerView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.shadow(10.dp)
|
.shadow(10.dp)
|
||||||
.background(ElementTheme.colors.bgCanvasDefault)
|
.background(ElementTheme.colors.bgCanvasDefault)
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.heightIn(max = 230.dp),
|
.heightIn(max = 230.dp),
|
||||||
roomId = state.roomId,
|
roomId = state.roomId,
|
||||||
roomName = state.roomName,
|
roomName = state.roomName,
|
||||||
roomAvatarData = state.roomAvatar,
|
roomAvatarData = state.roomAvatar,
|
||||||
@@ -467,9 +468,9 @@ private fun MessagesViewContent(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
AttachmentsBottomSheet(
|
AttachmentsBottomSheet(
|
||||||
state = state.composerState,
|
state = state.composerState,
|
||||||
@@ -520,25 +521,34 @@ private fun MessagesViewContent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||||
AnimatedVisibility(
|
Column {
|
||||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
AnimatedVisibility(
|
||||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||||
enter = expandVertically(),
|
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||||
exit = shrinkVertically(),
|
enter = expandVertically(),
|
||||||
) {
|
exit = shrinkVertically(),
|
||||||
fun focusOnPinnedEvent(eventId: EventId) {
|
) {
|
||||||
state.timelineState.eventSink(
|
fun focusOnPinnedEvent(eventId: EventId) {
|
||||||
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
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() {
|
private fun CantSendMessageBanner() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
|
|||||||
+3
-2
@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
|
|||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
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.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.aTimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
@@ -127,7 +128,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||||||
anActionListState(
|
anActionListState(
|
||||||
target = ActionListState.Target.Success(
|
target = ActionListState.Target.Success(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
content = aTimelineItemLocationContent(),
|
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
|
||||||
timelineItemReactions = reactionsState
|
timelineItemReactions = reactionsState
|
||||||
),
|
),
|
||||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||||
@@ -140,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||||||
anActionListState(
|
anActionListState(
|
||||||
target = ActionListState.Target.Success(
|
target = ActionListState.Target.Success(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
content = aTimelineItemLocationContent(),
|
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
|
||||||
timelineItemReactions = reactionsState
|
timelineItemReactions = reactionsState
|
||||||
),
|
),
|
||||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||||
|
|||||||
+6
@@ -136,6 +136,7 @@ class ThreadedMessagesNode(
|
|||||||
fun navigateToSendLocation()
|
fun navigateToSendLocation()
|
||||||
fun navigateToCreatePoll()
|
fun navigateToCreatePoll()
|
||||||
fun navigateToEditPoll(eventId: EventId)
|
fun navigateToEditPoll(eventId: EventId)
|
||||||
|
fun navigateToCurrentLiveLocation()
|
||||||
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
||||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||||
fun navigateToDeveloperSettings()
|
fun navigateToDeveloperSettings()
|
||||||
@@ -248,6 +249,11 @@ class ThreadedMessagesNode(
|
|||||||
callback.navigateToDeveloperSettings()
|
callback.navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToCurrentLiveLocation() {
|
||||||
|
// Shouldn't happen because LiveLocationSharingBanner is not shown in threads.
|
||||||
|
callback.navigateToCurrentLiveLocation()
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() = navigateUp()
|
override fun close() = navigateUp()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+5
-1
@@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
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.MessagesNavigator
|
||||||
import io.element.android.features.messages.impl.UserEventPermissions
|
import io.element.android.features.messages.impl.UserEventPermissions
|
||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
|
||||||
@@ -94,6 +95,7 @@ class TimelinePresenter(
|
|||||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
private val tag = "TimelinePresenter"
|
private val tag = "TimelinePresenter"
|
||||||
|
|
||||||
@@ -200,7 +202,9 @@ class TimelinePresenter(
|
|||||||
is TimelineEvent.EditPoll -> {
|
is TimelineEvent.EditPoll -> {
|
||||||
navigator.navigateToEditPoll(event.pollStartId)
|
navigator.navigateToEditPoll(event.pollStartId)
|
||||||
}
|
}
|
||||||
is TimelineEvent.StopLiveLocationShare -> Unit
|
is TimelineEvent.StopLiveLocationShare -> sessionCoroutineScope.launch {
|
||||||
|
liveLocationShareManager.stopShare(room.roomId)
|
||||||
|
}
|
||||||
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
|
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
|
||||||
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
||||||
delay(event.debounce)
|
delay(event.debounce)
|
||||||
|
|||||||
+1
-1
@@ -783,7 +783,7 @@ private fun MessageEventBubbleContent(
|
|||||||
val content = content.ensureActiveLiveLocation()
|
val content = content.ensureActiveLiveLocation()
|
||||||
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||||
content.mode.isActive &&
|
content.mode.isActive &&
|
||||||
content.mode.canStop
|
content.mode.isOwnUser
|
||||||
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||||
}
|
}
|
||||||
is TimelineItemPollContent -> TimestampPosition.Below
|
is TimelineItemPollContent -> TimestampPosition.Below
|
||||||
|
|||||||
+9
-8
@@ -13,14 +13,12 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -77,13 +75,14 @@ private fun LiveLocationOverlay(
|
|||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
|
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)),
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val iconShape = RoundedCornerShape(8.dp)
|
val iconShape = RoundedCornerShape(8.dp)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
// Ensure this Box uses same spacings than the Stop IconButton.
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.border(
|
.border(
|
||||||
width = 1.dp,
|
width = 1.dp,
|
||||||
@@ -120,7 +119,6 @@ private fun LiveLocationOverlay(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = if (mode.isActive) {
|
text = if (mode.isActive) {
|
||||||
@@ -140,13 +138,16 @@ private fun LiveLocationOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode.isActive && mode.canStop) {
|
if (mode.canStopSharing) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onStopClick,
|
onClick = onStopClick,
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
containerColor = ElementTheme.colors.bgCriticalPrimary,
|
containerColor = ElementTheme.colors.bgCriticalPrimary,
|
||||||
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
||||||
)
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.size(30.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CompoundIcons.Stop(),
|
imageVector = CompoundIcons.Stop(),
|
||||||
|
|||||||
+8
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.di
|
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.TimelineItemVoiceContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||||
@@ -18,6 +20,12 @@ import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
|||||||
*/
|
*/
|
||||||
fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories(
|
fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories(
|
||||||
mapOf(
|
mapOf(
|
||||||
|
Pair(
|
||||||
|
TimelineItemLocationContent::class,
|
||||||
|
TimelineItemPresenterFactory<TimelineItemLocationContent, TimelineItemLocationContent> { content ->
|
||||||
|
Presenter { content.ensureActiveLiveLocation() }
|
||||||
|
},
|
||||||
|
),
|
||||||
Pair(
|
Pair(
|
||||||
TimelineItemVoiceContent::class,
|
TimelineItemVoiceContent::class,
|
||||||
TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { Presenter { aVoiceMessageState() } },
|
TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { Presenter { aVoiceMessageState() } },
|
||||||
|
|||||||
+1
@@ -127,6 +127,7 @@ class TimelineItemContentFactory(
|
|||||||
isActive = itemContent.isLive,
|
isActive = itemContent.isLive,
|
||||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||||
endTimestamp = itemContent.endTimestamp,
|
endTimestamp = itemContent.endTimestamp,
|
||||||
|
isOwnUser = sessionId == sender
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||||||
aTimelineItemAudioContent(),
|
aTimelineItemAudioContent(),
|
||||||
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
|
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
|
||||||
aTimelineItemVoiceContent(),
|
aTimelineItemVoiceContent(),
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(mode = aStaticLocationMode()),
|
||||||
aTimelineItemPollContent(),
|
aTimelineItemPollContent(),
|
||||||
aTimelineItemNoticeContent(),
|
aTimelineItemNoticeContent(),
|
||||||
aTimelineItemRedactedContent(),
|
aTimelineItemRedactedContent(),
|
||||||
@@ -36,7 +36,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||||||
aTimelineItemTextContent().copy(isEdited = true),
|
aTimelineItemTextContent().copy(isEdited = true),
|
||||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||||
aTimelineItemLocationContent(
|
aTimelineItemLocationContent(
|
||||||
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
|
mode = aLiveLocationMode(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -72,9 +72,10 @@ data class TimelineItemLocationContent(
|
|||||||
val isActive: Boolean,
|
val isActive: Boolean,
|
||||||
val endsAt: String,
|
val endsAt: String,
|
||||||
val endTimestamp: Long,
|
val endTimestamp: Long,
|
||||||
val canStop: Boolean = false,
|
val isOwnUser: Boolean,
|
||||||
) : Mode {
|
) : Mode {
|
||||||
val isLoading = lastKnownLocation == null && isActive
|
val isLoading = lastKnownLocation == null && isActive
|
||||||
|
val canStopSharing = isActive && isOwnUser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-27
@@ -17,48 +17,44 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead
|
|||||||
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
|
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
|
||||||
override val values: Sequence<TimelineItemLocationContent>
|
override val values: Sequence<TimelineItemLocationContent>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemLocationContent(),
|
|
||||||
aTimelineItemLocationContent(
|
aTimelineItemLocationContent(
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = aStaticLocationMode()
|
||||||
isActive = true,
|
|
||||||
endsAt = "Ends at 12:34",
|
|
||||||
endTimestamp = 0L,
|
|
||||||
canStop = true,
|
|
||||||
lastKnownLocation = aLocation()
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
aTimelineItemLocationContent(
|
aTimelineItemLocationContent(
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = aLiveLocationMode(isActive = true)
|
||||||
isActive = true,
|
|
||||||
endsAt = "Ends at 12:34",
|
|
||||||
endTimestamp = 0L,
|
|
||||||
lastKnownLocation = aLocation()
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
aTimelineItemLocationContent(
|
aTimelineItemLocationContent(
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = aLiveLocationMode(isActive = true, lastKnownLocation = null)
|
||||||
isActive = true,
|
|
||||||
endsAt = "Ends at 12:34",
|
|
||||||
endTimestamp = 0L,
|
|
||||||
lastKnownLocation = null
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
aTimelineItemLocationContent(
|
aTimelineItemLocationContent(
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = aLiveLocationMode(isActive = true, isOwnUser = false)
|
||||||
isActive = false,
|
),
|
||||||
endsAt = "",
|
aTimelineItemLocationContent(
|
||||||
endTimestamp = 0L,
|
mode = aLiveLocationMode(isActive = false)
|
||||||
lastKnownLocation = aLocation()
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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(
|
fun aTimelineItemLocationContent(
|
||||||
senderId: UserId = UserId("@sender:matrix.org"),
|
senderId: UserId = UserId("@sender:matrix.org"),
|
||||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
|
mode: TimelineItemLocationContent.Mode,
|
||||||
) = TimelineItemLocationContent(
|
) = TimelineItemLocationContent(
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
|
|||||||
+5
@@ -28,6 +28,7 @@ class FakeMessagesNavigator(
|
|||||||
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
|
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
|
||||||
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||||
private val closeLambda: () -> Unit = { lambdaError() },
|
private val closeLambda: () -> Unit = { lambdaError() },
|
||||||
|
private val navigateToCurrentLiveLocationLambda: () -> Unit = { lambdaError() },
|
||||||
) : MessagesNavigator {
|
) : MessagesNavigator {
|
||||||
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
||||||
@@ -65,6 +66,10 @@ class FakeMessagesNavigator(
|
|||||||
navigateToDeveloperSettingsLambda()
|
navigateToDeveloperSettingsLambda()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToCurrentLiveLocation() {
|
||||||
|
navigateToCurrentLiveLocationLambda()
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
closeLambda()
|
closeLambda()
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -13,6 +13,7 @@ package io.element.android.features.messages.impl
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
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.ActionListEvent
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
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.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Suppress("LargeClass")
|
@Suppress("LargeClass")
|
||||||
class MessagesPresenterTest {
|
class MessagesPresenterTest {
|
||||||
@@ -140,6 +142,39 @@ class MessagesPresenterTest {
|
|||||||
assertThat(initialState.snackbarMessage).isNull()
|
assertThat(initialState.snackbarMessage).isNull()
|
||||||
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
|
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
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 = {},
|
actionListEventSink: (ActionListEvent) -> Unit = {},
|
||||||
addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
|
addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
|
||||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
|
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
|
||||||
|
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
@@ -1376,6 +1412,7 @@ class MessagesPresenterTest {
|
|||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
addRecentEmoji = addRecentEmoji,
|
addRecentEmoji = addRecentEmoji,
|
||||||
markAsFullyRead = markAsFullyRead,
|
markAsFullyRead = markAsFullyRead,
|
||||||
|
liveLocationShareManager = liveLocationShareManager,
|
||||||
sessionCoroutineScope = backgroundScope,
|
sessionCoroutineScope = backgroundScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
@@ -643,6 +643,44 @@ class MessagesViewTest {
|
|||||||
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
|
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
|
||||||
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
|
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(
|
private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
|
||||||
|
|||||||
+3
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
|
|||||||
|
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.FakeMessagesNavigator
|
||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
|
||||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||||
@@ -1012,6 +1013,7 @@ class TimelinePresenterTest {
|
|||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
|
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
): TimelinePresenter {
|
): TimelinePresenter {
|
||||||
return TimelinePresenter(
|
return TimelinePresenter(
|
||||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||||
@@ -1030,6 +1032,7 @@ class TimelinePresenterTest {
|
|||||||
roomCallStatePresenter = { aStandByCallState() },
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
liveLocationShareManager = liveLocationShareManager,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -20,4 +20,5 @@ sealed interface AdvancedSettingsEvents {
|
|||||||
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
|
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
|
||||||
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
|
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
|
||||||
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
|
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
|
||||||
|
data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvents
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -10,12 +10,14 @@ package io.element.android.features.preferences.impl.advanced
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
@@ -28,10 +30,12 @@ class AdvancedSettingsNode(
|
|||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
val context = LocalContext.current
|
||||||
AdvancedSettingsView(
|
AdvancedSettingsView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = ::navigateUp
|
onBackClick = ::navigateUp,
|
||||||
|
onOpenAppSettingsClick = context::openAppSettingsPage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -25,8 +25,11 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -53,6 +56,19 @@ class AdvancedSettingsPresenter(
|
|||||||
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
|
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
|
||||||
}.collectAsState(initial = Theme.System)
|
}.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 mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
|
||||||
|
|
||||||
val themeOption by remember {
|
val themeOption by remember {
|
||||||
@@ -117,6 +133,9 @@ class AdvancedSettingsPresenter(
|
|||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
|
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
|
||||||
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
|
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
|
||||||
|
is AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch {
|
||||||
|
appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value)
|
||||||
|
}
|
||||||
is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch {
|
||||||
sessionPreferencesStore.setOptimizeImages(event.compress)
|
sessionPreferencesStore.setOptimizeImages(event.compress)
|
||||||
}
|
}
|
||||||
@@ -133,6 +152,7 @@ class AdvancedSettingsPresenter(
|
|||||||
theme = themeOption,
|
theme = themeOption,
|
||||||
availableThemeOptions = availableThemeOptions,
|
availableThemeOptions = availableThemeOptions,
|
||||||
mediaPreviewConfigState = mediaPreviewConfigState,
|
mediaPreviewConfigState = mediaPreviewConfigState,
|
||||||
|
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@ data class AdvancedSettingsState(
|
|||||||
val theme: ThemeOption,
|
val theme: ThemeOption,
|
||||||
val availableThemeOptions: ImmutableList<ThemeOption>,
|
val availableThemeOptions: ImmutableList<ThemeOption>,
|
||||||
val mediaPreviewConfigState: MediaPreviewConfigState,
|
val mediaPreviewConfigState: MediaPreviewConfigState,
|
||||||
|
val liveLocationMinimumDistanceUpdate: Int?,
|
||||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
@@ -41,6 +41,7 @@ fun aAdvancedSettingsState(
|
|||||||
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
|
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
|
||||||
hideInviteAvatars: Boolean = false,
|
hideInviteAvatars: Boolean = false,
|
||||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||||
|
liveLocationMinimumDistanceUpdate: Int? = 50,
|
||||||
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (AdvancedSettingsEvents) -> Unit = {},
|
eventSink: (AdvancedSettingsEvents) -> Unit = {},
|
||||||
@@ -56,5 +57,6 @@ fun aAdvancedSettingsState(
|
|||||||
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
|
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
|
||||||
setHideInviteAvatarsAction = setHideInviteAvatarsAction
|
setHideInviteAvatarsAction = setHideInviteAvatarsAction
|
||||||
),
|
),
|
||||||
|
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|||||||
+96
-1
@@ -8,15 +8,24 @@
|
|||||||
|
|
||||||
package io.element.android.features.preferences.impl.advanced
|
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.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import im.vector.app.features.analytics.plan.Interaction
|
import im.vector.app.features.analytics.plan.Interaction
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.preferences.impl.R
|
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.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
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.ListSupportingText
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
|
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.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
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.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdvancedSettingsView(
|
fun AdvancedSettingsView(
|
||||||
state: AdvancedSettingsState,
|
state: AdvancedSettingsState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
|
onOpenAppSettingsClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val analyticsService = LocalAnalyticsService.current
|
val analyticsService = LocalAnalyticsService.current
|
||||||
@@ -190,6 +203,15 @@ fun AdvancedSettingsView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ModerationAndSafety(state)
|
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
|
@PreviewWithLargeHeight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
|
internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
|
||||||
@@ -334,7 +428,8 @@ internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettings
|
|||||||
private fun ContentToPreview(state: AdvancedSettingsState) {
|
private fun ContentToPreview(state: AdvancedSettingsState) {
|
||||||
AdvancedSettingsView(
|
AdvancedSettingsView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { }
|
onBackClick = { },
|
||||||
|
onOpenAppSettingsClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-1
@@ -17,6 +17,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
|||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
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.api.store.VideoCompressionPreset
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
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
|
@Test
|
||||||
fun `present - black theme option shown when feature flag enabled`() = runTest {
|
fun `present - black theme option shown when feature flag enabled`() = runTest {
|
||||||
val presenter = createAdvancedSettingsPresenter(
|
val presenter = createAdvancedSettingsPresenter(
|
||||||
@@ -338,7 +405,7 @@ class AdvancedSettingsPresenterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.createAdvancedSettingsPresenter(
|
private fun CoroutineScope.createAdvancedSettingsPresenter(
|
||||||
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
|
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
|
|||||||
+2
@@ -250,6 +250,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
|
|||||||
state: AdvancedSettingsState,
|
state: AdvancedSettingsState,
|
||||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onOpenAppSettings: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -258,6 +259,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
|
|||||||
AdvancedSettingsView(
|
AdvancedSettingsView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
|
onOpenAppSettingsClick = onOpenAppSettings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom
|
|||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
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.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.roomdirectory.RoomDirectoryService
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||||
@@ -67,6 +68,7 @@ interface MatrixClient {
|
|||||||
val sessionCoroutineScope: CoroutineScope
|
val sessionCoroutineScope: CoroutineScope
|
||||||
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
|
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
|
||||||
val roomMembershipObserver: RoomMembershipObserver
|
val roomMembershipObserver: RoomMembershipObserver
|
||||||
|
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
|
||||||
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
||||||
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
||||||
suspend fun findDM(userId: UserId): Result<RoomId?>
|
suspend fun findDM(userId: UserId): Result<RoomId?>
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.location
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
||||||
|
typealias BeaconId = EventId
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.location
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
data class BeaconInfoUpdate(
|
||||||
|
val roomId: RoomId,
|
||||||
|
val beaconId: BeaconId,
|
||||||
|
val isLive: Boolean,
|
||||||
|
)
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.location
|
||||||
|
|
||||||
|
sealed class LiveLocationException(message: String?) : Exception(message) {
|
||||||
|
class NotLive : LiveLocationException("The beacon event has expired.")
|
||||||
|
class Network : LiveLocationException("Network error")
|
||||||
|
class Other(val exception: Exception) : LiveLocationException(exception.message)
|
||||||
|
}
|
||||||
+2
@@ -21,6 +21,8 @@ data class LiveLocationShare(
|
|||||||
val startTimestamp: Long,
|
val startTimestamp: Long,
|
||||||
/** The timestamp when location sharing ends, in milliseconds. */
|
/** The timestamp when location sharing ends, in milliseconds. */
|
||||||
val endTimestamp: Long,
|
val endTimestamp: Long,
|
||||||
|
/** The event id from the beacon info. */
|
||||||
|
val beaconId: BeaconId
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LastLocation(
|
data class LastLocation(
|
||||||
|
|||||||
+12
@@ -70,6 +70,7 @@ import io.element.android.libraries.matrix.impl.room.RustRoomFactory
|
|||||||
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
|
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
|
||||||
import io.element.android.libraries.matrix.impl.room.history.map
|
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.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.room.preview.RoomPreviewInfoMapper
|
||||||
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
|
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
|
||||||
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
||||||
@@ -113,6 +114,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.matrix.rustcomponents.sdk.AuthData
|
import org.matrix.rustcomponents.sdk.AuthData
|
||||||
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
|
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.Client
|
||||||
import org.matrix.rustcomponents.sdk.ClientException
|
import org.matrix.rustcomponents.sdk.ClientException
|
||||||
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
||||||
@@ -207,6 +210,15 @@ class RustMatrixClient(
|
|||||||
analyticsService = analyticsService,
|
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(
|
override val sessionVerificationService = RustSessionVerificationService(
|
||||||
client = innerClient,
|
client = innerClient,
|
||||||
isSyncServiceReady = syncService.syncState.map { it == SyncState.Running },
|
isSyncServiceReady = syncService.syncState.map { it == SyncState.Running },
|
||||||
|
|||||||
+12
@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.impl.room.history.map
|
|||||||
import io.element.android.libraries.matrix.impl.room.join.map
|
import io.element.android.libraries.matrix.impl.room.join.map
|
||||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
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.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.location.timedByExpiry
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||||
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
|
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.DateDividerMode
|
||||||
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||||
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
||||||
|
import org.matrix.rustcomponents.sdk.LiveLocationException
|
||||||
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
||||||
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
|
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
|
||||||
import org.matrix.rustcomponents.sdk.SendQueueListener
|
import org.matrix.rustcomponents.sdk.SendQueueListener
|
||||||
@@ -525,12 +527,22 @@ class JoinedRustRoom(
|
|||||||
override suspend fun stopLiveLocationShare(): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun stopLiveLocationShare(): Result<Unit> = withContext(roomDispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
innerRoom.stopLiveLocationShare()
|
innerRoom.stopLiveLocationShare()
|
||||||
|
}.mapFailure { throwable ->
|
||||||
|
when (throwable) {
|
||||||
|
is LiveLocationException -> throwable.map()
|
||||||
|
else -> throwable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = withContext(roomDispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
innerRoom.sendLiveLocation(geoUri)
|
innerRoom.sendLiveLocation(geoUri)
|
||||||
|
}.mapFailure { throwable ->
|
||||||
|
when (throwable) {
|
||||||
|
is LiveLocationException -> throwable.map()
|
||||||
|
else -> throwable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.room.location
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
|
||||||
|
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate as RustBeaconInfoUpdate
|
||||||
|
|
||||||
|
fun RustBeaconInfoUpdate.map(): BeaconInfoUpdate {
|
||||||
|
return BeaconInfoUpdate(
|
||||||
|
roomId = RoomId(roomId),
|
||||||
|
beaconId = EventId(eventId),
|
||||||
|
isLive = live
|
||||||
|
)
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.room.location
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
|
||||||
|
import org.matrix.rustcomponents.sdk.LiveLocationException as RustLiveLocationException
|
||||||
|
|
||||||
|
fun RustLiveLocationException.map(): LiveLocationException {
|
||||||
|
return when (this) {
|
||||||
|
is RustLiveLocationException.Network -> LiveLocationException.Network()
|
||||||
|
is RustLiveLocationException.NotLive -> LiveLocationException.NotLive()
|
||||||
|
else -> LiveLocationException.Other(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.room.location
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
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.api.room.location.LiveLocationShare
|
||||||
@@ -41,9 +42,9 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return callbackFlow {
|
return callbackFlow {
|
||||||
val liveLocationShares = liveLocationsObserver()
|
val observer = liveLocationsObserver()
|
||||||
val shares: MutableList<LiveLocationShare> = ArrayList()
|
val shares: MutableList<LiveLocationShare> = ArrayList()
|
||||||
val taskHandle = liveLocationShares.subscribe(object : LiveLocationsListener {
|
val taskHandle = observer.subscribe(object : LiveLocationsListener {
|
||||||
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
|
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
|
||||||
for (update in updates) {
|
for (update in updates) {
|
||||||
shares.applyUpdate(update)
|
shares.applyUpdate(update)
|
||||||
@@ -53,13 +54,14 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
|
|||||||
})
|
})
|
||||||
awaitClose {
|
awaitClose {
|
||||||
taskHandle.cancelAndDestroy()
|
taskHandle.cancelAndDestroy()
|
||||||
liveLocationShares.destroy()
|
observer.destroy()
|
||||||
}
|
}
|
||||||
}.buffer(Channel.UNLIMITED)
|
}.buffer(Channel.UNLIMITED)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RustLiveLocationShare.into(): LiveLocationShare {
|
private fun RustLiveLocationShare.into(): LiveLocationShare {
|
||||||
return LiveLocationShare(
|
return LiveLocationShare(
|
||||||
|
beaconId = EventId(beaconId),
|
||||||
userId = UserId(userId),
|
userId = UserId(userId),
|
||||||
lastLocation = lastLocation?.let {
|
lastLocation = lastLocation?.let {
|
||||||
LastLocation(
|
LastLocation(
|
||||||
@@ -69,6 +71,6 @@ private fun RustLiveLocationShare.into(): LiveLocationShare {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
startTimestamp = startTs.toLong(),
|
startTimestamp = startTs.toLong(),
|
||||||
endTimestamp = (startTs + timeout).toLong()
|
endTimestamp = (startTs + timeout).toLong(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-19
@@ -11,6 +11,7 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.api.room.location.LiveLocationShare
|
||||||
|
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
@@ -24,9 +25,9 @@ class TimedLiveLocationSharesFlowTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest {
|
fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest {
|
||||||
val shares = listOf(
|
val shares = listOf(
|
||||||
aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000),
|
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 1_000),
|
||||||
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000),
|
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
|
||||||
aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000),
|
aLiveLocationShare(userId = UserId("@carol:server"), endTimestamp = 3_000),
|
||||||
)
|
)
|
||||||
|
|
||||||
flowOf(shares)
|
flowOf(shares)
|
||||||
@@ -56,8 +57,8 @@ class TimedLiveLocationSharesFlowTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `it does not double-emit when a share is already expired on receipt`() = runTest {
|
fun `it does not double-emit when a share is already expired on receipt`() = runTest {
|
||||||
val shares = listOf(
|
val shares = listOf(
|
||||||
aLiveLocationShare(userId = "@alice:server", endTimestamp = 500),
|
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 500),
|
||||||
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000),
|
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
|
||||||
)
|
)
|
||||||
|
|
||||||
flowOf(shares)
|
flowOf(shares)
|
||||||
@@ -81,8 +82,8 @@ class TimedLiveLocationSharesFlowTest {
|
|||||||
val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1)
|
val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1)
|
||||||
val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000))
|
val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000))
|
||||||
val updatedShares = listOf(
|
val updatedShares = listOf(
|
||||||
aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000),
|
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 10_000),
|
||||||
aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000),
|
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 6_000),
|
||||||
)
|
)
|
||||||
|
|
||||||
upstream
|
upstream
|
||||||
@@ -133,15 +134,3 @@ class TimedLiveLocationSharesFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aLiveLocationShare(
|
|
||||||
userId: String = "@user:server",
|
|
||||||
endTimestamp: Long,
|
|
||||||
): LiveLocationShare {
|
|
||||||
return LiveLocationShare(
|
|
||||||
userId = UserId(userId),
|
|
||||||
lastLocation = null,
|
|
||||||
startTimestamp = 0L,
|
|
||||||
endTimestamp = endTimestamp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+2
@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom
|
|||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
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.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.roomdirectory.RoomDirectoryService
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||||
@@ -107,6 +108,7 @@ class FakeMatrixClient(
|
|||||||
private val canReportRoomLambda: () -> Boolean = { false },
|
private val canReportRoomLambda: () -> Boolean = { false },
|
||||||
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
||||||
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
||||||
|
override val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate> = emptyFlow(),
|
||||||
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
|
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
|
||||||
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
|
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
|
||||||
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ class FakeJoinedRoom(
|
|||||||
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
|
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
|
||||||
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
|
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
|
||||||
private val liveLocationSharesFlow: Flow<List<LiveLocationShare>> = MutableStateFlow(emptyList()),
|
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 stopLiveLocationShareResult: () -> Result<Unit> = { lambdaError() },
|
||||||
private val sendLiveLocationResult: (String) -> Result<Unit> = { lambdaError() },
|
private val sendLiveLocationResult: (String) -> Result<Unit> = { lambdaError() },
|
||||||
) : JoinedRoom, BaseRoom by baseRoom {
|
) : JoinedRoom, BaseRoom by baseRoom {
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.test.room.location
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
||||||
|
fun aLiveLocationShare(
|
||||||
|
beaconId: EventId = AN_EVENT_ID,
|
||||||
|
userId: UserId = A_USER_ID,
|
||||||
|
geoUri: String = "geo:48.8584,2.2945",
|
||||||
|
timestamp: Long = 0L,
|
||||||
|
startTimestamp: Long = 0L,
|
||||||
|
endTimestamp: Long = Long.MAX_VALUE,
|
||||||
|
assetType: AssetType = AssetType.SENDER,
|
||||||
|
): LiveLocationShare {
|
||||||
|
return LiveLocationShare(
|
||||||
|
beaconId = beaconId,
|
||||||
|
userId = userId,
|
||||||
|
lastLocation = LastLocation(
|
||||||
|
geoUri = geoUri,
|
||||||
|
timestamp = timestamp,
|
||||||
|
assetType = assetType,
|
||||||
|
),
|
||||||
|
startTimestamp = startTimestamp,
|
||||||
|
endTimestamp = endTimestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
@@ -23,6 +23,9 @@ interface AppPreferencesStore {
|
|||||||
suspend fun setTheme(theme: String)
|
suspend fun setTheme(theme: String)
|
||||||
fun getThemeFlow(): Flow<String?>
|
fun getThemeFlow(): Flow<String?>
|
||||||
|
|
||||||
|
suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int)
|
||||||
|
fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow<Int>
|
||||||
|
|
||||||
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
suspend fun setHideInviteAvatars(hide: Boolean?)
|
suspend fun setHideInviteAvatars(hide: Boolean?)
|
||||||
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
@@ -26,4 +27,6 @@ dependencies {
|
|||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
|
testCommonDependencies(libs)
|
||||||
|
testImplementation(projects.libraries.preferences.test)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -10,6 +10,7 @@ package io.element.android.libraries.preferences.impl.store
|
|||||||
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
@@ -28,6 +29,7 @@ private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseU
|
|||||||
private val themeKey = stringPreferencesKey("theme")
|
private val themeKey = stringPreferencesKey("theme")
|
||||||
private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
|
private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
|
||||||
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
|
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
|
||||||
|
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
|
||||||
private val logLevelKey = stringPreferencesKey("logLevel")
|
private val logLevelKey = stringPreferencesKey("logLevel")
|
||||||
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
|
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.")
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
||||||
return store.data.map { prefs ->
|
return store.data.map { prefs ->
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.preferences.impl.store
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultAppPreferencesStoreTest {
|
||||||
|
private val buildMeta = BuildMeta(
|
||||||
|
buildType = BuildType.DEBUG,
|
||||||
|
isDebuggable = true,
|
||||||
|
applicationName = "Element X",
|
||||||
|
productionApplicationName = "Element",
|
||||||
|
desktopApplicationName = "Element Desktop",
|
||||||
|
applicationId = "io.element.android",
|
||||||
|
isEnterpriseBuild = false,
|
||||||
|
lowPrivacyLoggingEnabled = false,
|
||||||
|
versionName = "1.0.0",
|
||||||
|
versionCode = 1,
|
||||||
|
gitRevision = "test",
|
||||||
|
gitBranchName = "test",
|
||||||
|
flavorDescription = "test",
|
||||||
|
flavorShortDescription = "test",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `live location minimum distance defaults to 10`() = runTest {
|
||||||
|
val store = DefaultAppPreferencesStore(
|
||||||
|
buildMeta = buildMeta,
|
||||||
|
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `live location minimum distance persists updates`() = runTest {
|
||||||
|
val store = DefaultAppPreferencesStore(
|
||||||
|
buildMeta = buildMeta,
|
||||||
|
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationMinimumDistanceInMetersUpdate(25)
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -21,12 +21,14 @@ class InMemoryAppPreferencesStore(
|
|||||||
hideInviteAvatars: Boolean? = null,
|
hideInviteAvatars: Boolean? = null,
|
||||||
timelineMediaPreviewValue: MediaPreviewValue? = null,
|
timelineMediaPreviewValue: MediaPreviewValue? = null,
|
||||||
theme: String? = null,
|
theme: String? = null,
|
||||||
|
liveLocationMinimumDistanceUpdate: Int = 10,
|
||||||
logLevel: LogLevel = LogLevel.INFO,
|
logLevel: LogLevel = LogLevel.INFO,
|
||||||
traceLockPacks: Set<TraceLogPack> = emptySet(),
|
traceLockPacks: Set<TraceLogPack> = emptySet(),
|
||||||
) : AppPreferencesStore {
|
) : AppPreferencesStore {
|
||||||
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
||||||
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
||||||
private val theme = MutableStateFlow(theme)
|
private val theme = MutableStateFlow(theme)
|
||||||
|
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
|
||||||
private val logLevel = MutableStateFlow(logLevel)
|
private val logLevel = MutableStateFlow(logLevel)
|
||||||
private val tracingLogPacks = MutableStateFlow(traceLockPacks)
|
private val tracingLogPacks = MutableStateFlow(traceLockPacks)
|
||||||
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
|
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
|
||||||
@@ -56,6 +58,14 @@ class InMemoryAppPreferencesStore(
|
|||||||
return theme
|
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.")
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
||||||
return hideInviteAvatars
|
return hideInviteAvatars
|
||||||
|
|||||||
+1
@@ -53,4 +53,5 @@ object NotificationIdProvider {
|
|||||||
enum class ForegroundServiceType {
|
enum class ForegroundServiceType {
|
||||||
INCOMING_CALL,
|
INCOMING_CALL,
|
||||||
ONGOING_CALL,
|
ONGOING_CALL,
|
||||||
|
LIVE_LOCATION,
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -10,12 +10,13 @@ package io.element.android.libraries.sessionstorage.test.observer
|
|||||||
|
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
|
||||||
class FakeSessionObserver : SessionObserver {
|
class FakeSessionObserver : SessionObserver {
|
||||||
private val _listeners = mutableListOf<SessionListener>()
|
private val _listeners = CopyOnWriteArraySet<SessionListener>()
|
||||||
|
|
||||||
val listeners: List<SessionListener>
|
val listeners: List<SessionListener>
|
||||||
get() = _listeners
|
get() = _listeners.toList()
|
||||||
|
|
||||||
override fun addListener(listener: SessionListener) {
|
override fun addListener(listener: SessionListener) {
|
||||||
_listeners.add(listener)
|
_listeners.add(listener)
|
||||||
|
|||||||
+4
@@ -34,6 +34,8 @@ interface AppForegroundStateService {
|
|||||||
*/
|
*/
|
||||||
val isSyncingNotificationEvent: StateFlow<Boolean>
|
val isSyncingNotificationEvent: StateFlow<Boolean>
|
||||||
|
|
||||||
|
val isSharingLiveLocation: StateFlow<Boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start observing the foreground state.
|
* Start observing the foreground state.
|
||||||
*/
|
*/
|
||||||
@@ -53,4 +55,6 @@ interface AppForegroundStateService {
|
|||||||
* Update the active state for the syncing notification event flow.
|
* Update the active state for the syncing notification event flow.
|
||||||
*/
|
*/
|
||||||
fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean)
|
fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean)
|
||||||
|
|
||||||
|
fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -20,6 +20,8 @@ class DefaultAppForegroundStateService : AppForegroundStateService {
|
|||||||
override val isSyncingNotificationEvent = MutableStateFlow(false)
|
override val isSyncingNotificationEvent = MutableStateFlow(false)
|
||||||
override val hasRingingCall = MutableStateFlow(false)
|
override val hasRingingCall = MutableStateFlow(false)
|
||||||
|
|
||||||
|
override val isSharingLiveLocation = MutableStateFlow<Boolean>(false)
|
||||||
|
|
||||||
private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
|
private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
|
||||||
|
|
||||||
override fun startObservingForeground() {
|
override fun startObservingForeground() {
|
||||||
@@ -38,6 +40,10 @@ class DefaultAppForegroundStateService : AppForegroundStateService {
|
|||||||
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
|
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) {
|
||||||
|
this.isSharingLiveLocation.value = isSharingLiveLocation
|
||||||
|
}
|
||||||
|
|
||||||
private val lifecycleObserver = LifecycleEventObserver { _, _ -> isInForeground.value = getCurrentState() }
|
private val lifecycleObserver = LifecycleEventObserver { _, _ -> isInForeground.value = getCurrentState() }
|
||||||
|
|
||||||
private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
|||||||
+7
@@ -16,12 +16,15 @@ class FakeAppForegroundStateService(
|
|||||||
initialIsInCallValue: Boolean = false,
|
initialIsInCallValue: Boolean = false,
|
||||||
initialIsSyncingNotificationEventValue: Boolean = false,
|
initialIsSyncingNotificationEventValue: Boolean = false,
|
||||||
initialHasRingingCall: Boolean = false,
|
initialHasRingingCall: Boolean = false,
|
||||||
|
initialIsSharingLiveLocation: Boolean = false,
|
||||||
) : AppForegroundStateService {
|
) : AppForegroundStateService {
|
||||||
override val isInForeground = MutableStateFlow(initialForegroundValue)
|
override val isInForeground = MutableStateFlow(initialForegroundValue)
|
||||||
override val isInCall = MutableStateFlow(initialIsInCallValue)
|
override val isInCall = MutableStateFlow(initialIsInCallValue)
|
||||||
override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue)
|
override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue)
|
||||||
override val hasRingingCall = MutableStateFlow(initialHasRingingCall)
|
override val hasRingingCall = MutableStateFlow(initialHasRingingCall)
|
||||||
|
|
||||||
|
override val isSharingLiveLocation = MutableStateFlow<Boolean>(initialIsSharingLiveLocation)
|
||||||
|
|
||||||
override fun startObservingForeground() {
|
override fun startObservingForeground() {
|
||||||
// No-op
|
// No-op
|
||||||
}
|
}
|
||||||
@@ -41,4 +44,8 @@ class FakeAppForegroundStateService(
|
|||||||
override fun updateHasRingingCall(hasRingingCall: Boolean) {
|
override fun updateHasRingingCall(hasRingingCall: Boolean) {
|
||||||
this.hasRingingCall.value = hasRingingCall
|
this.hasRingingCall.value = hasRingingCall
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) {
|
||||||
|
this.isSharingLiveLocation.value = isSharingLiveLocation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
tests/uitests/src/test/snapshots/images/features.location.api_LiveLocationSharingBanner_Day_0_en.png
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user