From ac0ad510c83cbf75010775febbde4830389c87d9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Jul 2026 12:44:51 +0200 Subject: [PATCH 1/3] Stop live location share on location tick when past expiry --- .../DefaultActiveLiveLocationShareManager.kt | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt index c24d3f9cc2..36744664cb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt @@ -52,9 +52,11 @@ class DefaultActiveLiveLocationShareManager( private val clock: SystemClock, private val sessionObserver: SessionObserver, ) : ActiveLiveLocationShareManager, LiveLocationReceiver { + private data class Timeout(val expiresAt: Instant, val job: Job) + private val isSetup = AtomicBoolean(false) private val cachedRooms = ConcurrentHashMap() - private val timeoutJobs = ConcurrentHashMap() + private val timeouts = ConcurrentHashMap() private val syncedActiveShareIds = MutableStateFlow>(emptySet()) private val localSharingRoomIds = MutableStateFlow>(emptySet()) override val sharingRoomIds: StateFlow> = localSharingRoomIds @@ -139,9 +141,18 @@ class DefaultActiveLiveLocationShareManager( } override suspend fun onLocationUpdate(location: Location) { - val activeSharesCount = localSharingRoomIds.value.size + val nowMillis = clock.epochMillis() + val (expired, active) = localSharingRoomIds.value.partition { roomId -> + val timeout = timeouts[roomId] ?: return@partition false + timeout.expiresAt.toEpochMilliseconds() <= nowMillis + } + expired.forEach { roomId -> + Timber.d("ActiveLiveLocationShareManager location tick detected expired share for room $roomId, stopping") + stopShare(roomId) + } + val activeSharesCount = active.size Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)") - localSharingRoomIds.value.forEach { roomId -> + active.forEach { roomId -> Timber.d("ActiveLiveLocationShareManager sending location to room $roomId") sendLiveLocation(roomId, location) .onFailure { @@ -192,20 +203,21 @@ class DefaultActiveLiveLocationShareManager( } private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) { - timeoutJobs.remove(roomId)?.cancel() + timeouts.remove(roomId)?.job?.cancel() val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis() - timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch { + val job = matrixClient.sessionCoroutineScope.launch { delay(delayMillis) stopShare(roomId) .onFailure { error -> Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId") } } + timeouts[roomId] = Timeout(expiresAt = expiresAt, job = job) } private suspend fun stopLocalShare(roomId: RoomId) { Timber.d("ActiveLiveLocationShareManager stop local share in $roomId") - timeoutJobs.remove(roomId)?.cancel() + timeouts.remove(roomId)?.job?.cancel() val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty() cachedRooms.remove(roomId)?.close() liveLocationStore.removeLiveLocationExpiry(roomId) @@ -220,11 +232,11 @@ class DefaultActiveLiveLocationShareManager( sessionObserver.removeListener(sessionListener) coordinator.unregister(matrixClient.sessionId) liveLocationStore.clear() + timeouts.values.forEach { it.job.cancel() } + timeouts.clear() for (room in cachedRooms.values) { room.close() - timeoutJobs[room.roomId]?.cancel() } - timeoutJobs.clear() cachedRooms.clear() localSharingRoomIds.value = emptySet() syncedActiveShareIds.value = emptySet() From 9d76f0c9207873d5c197a3153260fea0ce835867 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Jul 2026 12:46:26 +0200 Subject: [PATCH 2/3] Test location-tick stops live location share past expiry --- ...faultActiveLiveLocationShareManagerTest.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt index 85f0e1c33f..9c15424cad 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt @@ -373,6 +373,53 @@ class DefaultActiveLiveLocationShareManagerTest { } } + @Test + fun `location update after expiry stops the share and does not send location`() = runTest { + val liveLocationStore = createInMemoryLiveLocationStore() + val beaconInfoUpdates = MutableSharedFlow(replay = 1) + val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) } + val sendLiveLocationResult = lambdaRecorder> { _ -> Result.success(Unit) } + val clock = FakeSystemClock(epochMillisResult = 1_000L) + 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, + sendLiveLocationResult = sendLiveLocationResult, + ), + ) + }, + coordinator = createCoordinator(), + liveLocationStore = liveLocationStore, + clock = clock, + ) + 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() + + // Advance the clock past the expiry so the next location tick is beyond the deadline. + clock.epochMillisResult = 1_000L + 1.minutes.inWholeMilliseconds + 1_000L + + manager.sharingRoomIds.test { + assertThat(awaitItem()).containsExactly(A_ROOM_ID) + manager.onLocationUpdate(io.element.android.features.location.api.Location(lat = 0.0, lon = 0.0, accuracy = null)) + assertThat(awaitItem()).isEmpty() + advanceUntilIdle() + } + assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID) + assert(sendLiveLocationResult).isNeverCalled() + // stopLiveLocationShare is called twice: once defensively in startShare, once from the tick-triggered stopShare. + assert(stopLiveLocationShareResult).isCalledExactly(2) + } + @Test fun `session deleted clears local state`() = runTest { val startServiceRecorder = lambdaRecorder { } From a6b5eb4dce8639ca753b25ce07c090cfda4526e0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 15 Jul 2026 21:22:23 +0200 Subject: [PATCH 3/3] Extract expired-share handling into a helper method --- .../DefaultActiveLiveLocationShareManager.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt index 36744664cb..33ce06454d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt @@ -141,6 +141,18 @@ class DefaultActiveLiveLocationShareManager( } override suspend fun onLocationUpdate(location: Location) { + val active = stopExpiredShares() + Timber.d("ActiveLiveLocationShareManager received location update for ${active.size} active share(s)") + active.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 stopExpiredShares(): List { val nowMillis = clock.epochMillis() val (expired, active) = localSharingRoomIds.value.partition { roomId -> val timeout = timeouts[roomId] ?: return@partition false @@ -150,15 +162,7 @@ class DefaultActiveLiveLocationShareManager( Timber.d("ActiveLiveLocationShareManager location tick detected expired share for room $roomId, stopping") stopShare(roomId) } - val activeSharesCount = active.size - Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)") - active.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") - } - } + return active } private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result {