From 11584d6bfe4a84da1af1fb243cfef329964997fa Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:18:36 +0200 Subject: [PATCH] Live Location Sharing - handle server echoes (#5514) * Track active live location sessions by ID instead of timeout. # Conflicts: # ElementX/Sources/Services/Location/LiveLocationManager.swift * implemented a system to promote starting session to active sesessions to send locations at the right time, and a system to remove a local session if it's handled by an external device. * pr suggestions --------- Co-authored-by: Doug --- ElementX.xcodeproj/project.pbxproj | 8 ++ .../Application/Settings/AppSettings.swift | 4 +- .../Settings/LiveLocationSession.swift | 13 +++ ElementX/Sources/Mocks/ClientProxyMock.swift | 2 + .../Mocks/Generated/GeneratedMocks.swift | 15 ++-- ElementX/Sources/Other/SDKListener.swift | 6 ++ .../RoomScreen/RoomScreenViewModel.swift | 6 +- .../Sources/Services/Client/ClientProxy.swift | 27 +++++- .../Services/Client/ClientProxyProtocol.swift | 15 ++++ .../Location/LiveLocationManager.swift | 89 +++++++++++++------ .../Services/Room/JoinedRoomProxy.swift | 6 +- .../Services/Room/RoomProxyProtocol.swift | 2 +- .../Sources/LiveLocationManagerTests.swift | 77 ++++++++++++---- 13 files changed, 209 insertions(+), 61 deletions(-) create mode 100644 ElementX/Sources/Application/Settings/LiveLocationSession.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e0ff42b75..2db065590 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -835,6 +835,7 @@ 8A83D715940378B9BA9F739E /* RoomInviterLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */; }; 8AA84EF202F2EFC8453A97BD /* SecureBackupRecoveryKeyScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E027C112740573D27765C /* SecureBackupRecoveryKeyScreenModels.swift */; }; 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */; }; + 8AEE47A14223423806A7653A /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; 8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260004737C573A56FA01E86E /* Encodable.swift */; }; 8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */; }; 8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; }; @@ -963,6 +964,7 @@ A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */; }; A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; }; A0601810597769B81C2358AF /* EncryptionResetPasswordScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2B5274C1D3D2999D643786 /* EncryptionResetPasswordScreenViewModelProtocol.swift */; }; + A0646F23876D6326AD27FF52 /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; A07178337F3C0B208B5A77A8 /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB6ED50FE104992419310EEB /* NotificationHandler.swift */; }; A076E0A9338FD2D950C3C4A1 /* ChatsSpaceFiltersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63072981793CCA84EE12798 /* ChatsSpaceFiltersScreenViewModelProtocol.swift */; }; A0861B727B273B5B3DD7FBF6 /* KnockRequestsListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */; }; @@ -1266,6 +1268,7 @@ D33AC79A50DFC26D2498DD28 /* FileRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */; }; D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0D98D372B17EAE9AA999 /* GlobalSearchScreenModels.swift */; }; D38E59C48BE5499A48D12031 /* CreateRoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AC8FCE224D4185F28636FF /* CreateRoomScreenCoordinator.swift */; }; + D3ED5692672892F9F1E7375A /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; D3FD96913D2B1AAA3149DAC7 /* CreateRoomViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */; }; D433A58BFF77B3E563FB547E /* RoomCallControlsToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48A2FA6814F824ABB4C07F3 /* RoomCallControlsToolbar.swift */; }; D46C33F8B61B55F0C8C2D15F /* LocationRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2AC540DE619B36832A5DB5 /* LocationRoomTimelineItem.swift */; }; @@ -2843,6 +2846,7 @@ D97A4E73EA97CA08D2BB9806 /* RoomScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenTests.swift; sourceTree = ""; }; D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreen.swift; sourceTree = ""; }; DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = ""; }; + DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSession.swift; sourceTree = ""; }; DA3D82522494E78746B2214E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/SAS.strings; sourceTree = ""; }; DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLLocationManagerProtocol.swift; sourceTree = ""; }; DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCache.swift; sourceTree = ""; }; @@ -6513,6 +6517,7 @@ 7BD5523BDEDB247E29228476 /* AppSettings.swift */, F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */, 0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */, + DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */, 8A1F2AAA3F0F2B72D2FFE4D0 /* MapTilerConfiguration.swift */, E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */, 8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */, @@ -7778,6 +7783,7 @@ 8691186F9B99BCDDB7CACDD8 /* KeychainController.swift in Sources */, A440D4BC02088482EC633A88 /* KeychainControllerProtocol.swift in Sources */, FB0A9D06FC9122E37992D962 /* LayoutDirection.swift in Sources */, + D3ED5692672892F9F1E7375A /* LiveLocationSession.swift in Sources */, 0728314DD51AC3819F818EA8 /* LogLevel.swift in Sources */, AD2A81B65A9F6163012086F1 /* MXLog.swift in Sources */, 9AC47275B8E1EB0976BA7A80 /* MapTilerConfiguration.swift in Sources */, @@ -8009,6 +8015,7 @@ 05FF0CD80EDAB3A7C0D4700A /* InfoPlistReader.swift in Sources */, FC31493979ED1FDF7D5EA3F9 /* KeychainController.swift in Sources */, 5618ED25F092DF5712003829 /* KeychainControllerProtocol.swift in Sources */, + A0646F23876D6326AD27FF52 /* LiveLocationSession.swift in Sources */, DA10C99BA43A0F1E732F6274 /* LogLevel.swift in Sources */, 0638CBDE3098B1C3F23AFCFA /* MXLog.swift in Sources */, 074F741578307EF0179EE47C /* MapTilerConfiguration.swift in Sources */, @@ -8467,6 +8474,7 @@ CD077E14FAADC444C5A80068 /* LiveLocationManagerProtocol.swift in Sources */, C8D0AC22E03F652118A2BB73 /* LiveLocationRoomTimelineItem.swift in Sources */, F7977C53B2B1D73030C69761 /* LiveLocationRoomTimelineView.swift in Sources */, + 8AEE47A14223423806A7653A /* LiveLocationSession.swift in Sources */, 633400018E07D2DC7175B16E /* LiveLocationShareProxy.swift in Sources */, 9223E5F2A2CE0AFFDFF0AFFB /* LiveLocationSharingBannerView.swift in Sources */, EB5B79DD2BCAF8F3B8B01F2F /* LiveLocationSheet.swift in Sources */, diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 88905c281..cced885e8 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -352,8 +352,8 @@ final class AppSettings { // MARK: - Live Location - @UserPreference(key: UserDefaultsKeys.liveLocationSharingTimeoutDatesByRoomID, defaultValue: [String: Date](), storageType: .userDefaults(store)) - var liveLocationSharingTimeoutDatesByRoomID + @UserPreference(key: UserDefaultsKeys.liveLocationSharingTimeoutDatesByRoomID, defaultValue: [String: LiveLocationSession](), storageType: .userDefaults(store)) + var liveLocationSharingSessionsByRoomID @UserPreference(key: UserDefaultsKeys.liveLocationMinimumDistanceUpdate, defaultValue: 10, storageType: .userDefaults(store)) var liveLocationMinimumDistanceUpdate diff --git a/ElementX/Sources/Application/Settings/LiveLocationSession.swift b/ElementX/Sources/Application/Settings/LiveLocationSession.swift new file mode 100644 index 000000000..cd60b8f52 --- /dev/null +++ b/ElementX/Sources/Application/Settings/LiveLocationSession.swift @@ -0,0 +1,13 @@ +// +// Copyright 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. +// + +import Foundation + +struct LiveLocationSession: Codable, Equatable { + let eventID: String + let expirationDate: Date +} diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index c6eb56b68..84dae9a9a 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -159,6 +159,8 @@ extension ClientProxyMock { underlyingTimelineMediaVisibilityPublisher = CurrentValueSubject(configuration.timelineMediaVisibility).asCurrentValuePublisher() underlyingHideInviteAvatarsPublisher = CurrentValueSubject(configuration.hideInviteAvatars).asCurrentValuePublisher() + + liveLocationOwnInfoUpdatesPublisher = PassthroughSubject().eraseToAnyPublisher() underlyingMaxMediaUploadSize = .success(configuration.maxMediaUploadSize) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 086e82fd3..ee3128987 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2854,6 +2854,11 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { } var underlyingMaxMediaUploadSize: Result! var maxMediaUploadSizeClosure: (() async -> Result)? + var liveLocationOwnInfoUpdatesPublisher: AnyPublisher { + get { return underlyingLiveLocationOwnInfoUpdatesPublisher } + set(value) { underlyingLiveLocationOwnInfoUpdatesPublisher = value } + } + var underlyingLiveLocationOwnInfoUpdatesPublisher: AnyPublisher! //MARK: - isOnlyDeviceLeft @@ -10766,13 +10771,13 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { var startLiveLocationShareDurationReceivedDuration: Duration? var startLiveLocationShareDurationReceivedInvocations: [Duration] = [] - var startLiveLocationShareDurationUnderlyingReturnValue: Result! - var startLiveLocationShareDurationReturnValue: Result! { + var startLiveLocationShareDurationUnderlyingReturnValue: Result! + var startLiveLocationShareDurationReturnValue: Result! { get { if Thread.isMainThread { return startLiveLocationShareDurationUnderlyingReturnValue } else { - var returnValue: Result? = nil + var returnValue: Result? = nil DispatchQueue.main.sync { returnValue = startLiveLocationShareDurationUnderlyingReturnValue } @@ -10790,9 +10795,9 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } } } - var startLiveLocationShareDurationClosure: ((Duration) async -> Result)? + var startLiveLocationShareDurationClosure: ((Duration) async -> Result)? - func startLiveLocationShare(duration: Duration) async -> Result { + func startLiveLocationShare(duration: Duration) async -> Result { startLiveLocationShareDurationCallsCount += 1 startLiveLocationShareDurationReceivedDuration = duration DispatchQueue.main.async { diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index 121fd5a8c..e2d88c612 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -203,6 +203,12 @@ extension SDKListener: LiveLocationsListener where T == [LiveLocationShareUpdate } } +extension SDKListener: BeaconInfoListener where T == BeaconInfoUpdate { + func onUpdate(update: BeaconInfoUpdate) { + onUpdateClosure(update) + } +} + extension SDKListener: ThreadListEntriesListener where T == [ThreadListUpdate] { func onUpdate(diff: [ThreadListUpdate]) { onUpdateClosure(diff) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index b8e8d0f5d..9aa2fa267 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -171,11 +171,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .weakAssign(to: \.state.isKnockingEnabled, on: self) .store(in: &cancellables) - appSettings.$liveLocationSharingTimeoutDatesByRoomID + appSettings.$liveLocationSharingSessionsByRoomID .receive(on: DispatchQueue.main) - .sink { [weak self] timeoutDatesByRoomID in + .sink { [weak self] sessionsByRoomID in guard let self else { return } - state.isSharingLiveLocation = timeoutDatesByRoomID.keys.contains(roomProxy.id) + state.isSharingLiveLocation = sessionsByRoomID.keys.contains(roomProxy.id) } .store(in: &cancellables) diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 979c90fb8..54ebd3911 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -46,6 +46,9 @@ class ClientProxy: ClientProxyProtocol { // periphery:ignore - required for instance retention in the rust codebase private var mediaPreviewConfigListenerTaskHandle: TaskHandle? + + // periphery:ignore - required for instance retention in the rust codebase + private var liveLocationOwnInfoUpdatesListenerTaskHandle: TaskHandle? private var delegateHandle: TaskHandle? @@ -188,7 +191,12 @@ class ClientProxy: ClientProxyProtocol { } var roomsToAwait: Set = [] - + + private let liveLocationOwnInfoUpdatesSubject = PassthroughSubject() + var liveLocationOwnInfoUpdatesPublisher: AnyPublisher { + liveLocationOwnInfoUpdatesSubject.eraseToAnyPublisher() + } + private let sendQueueStatusSubject = CurrentValueSubject(false) init(client: ClientProtocol, @@ -270,6 +278,8 @@ class ClientProxy: ClientProxyProtocol { Task { mediaPreviewConfigListenerTaskHandle = await createMediaPreviewConfigObserver() } + + liveLocationOwnInfoUpdatesListenerTaskHandle = createLiveLocationOwnInfoUpdatesObserver() } var userID: String { @@ -1136,6 +1146,21 @@ class ClientProxy: ClientProxyProtocol { } } + private func createLiveLocationOwnInfoUpdatesObserver() -> TaskHandle? { + do { + return try client.subscribeToOwnBeaconInfoUpdates(listener: SDKListener { [weak self] update in + guard let self else { return } + let appUpdate = LiveLocationOwnInfoUpdate(roomID: update.roomId, + eventID: update.eventId, + isLive: update.live) + liveLocationOwnInfoUpdatesSubject.send(appUpdate) + }) + } catch { + MXLog.error("Failed creating own beacon info updates observer: \(error)") + return nil + } + } + private func createRoomListServiceObserver(_ roomListService: RoomListService) -> TaskHandle { roomListService.state(listener: SDKListener { [weak self] state in guard let self else { return } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index eab45ffc1..575f8fd7b 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -90,6 +90,16 @@ enum TimelineMediaVisibility: Decodable { case never } +/// Represents a server-echoed update about the current user's own beacon info state in a room. +struct LiveLocationOwnInfoUpdate: Equatable { + /// The room where the beacon info event was sent. + let roomID: String + /// The event ID of the beacon info state event. + let eventID: String + /// Whether the beacon is currently active (live) or has been stopped. + let isLive: Bool +} + // sourcery: AutoMockable protocol ClientProxyProtocol: AnyObject { var actionsPublisher: AnyPublisher { get } @@ -264,6 +274,11 @@ protocol ClientProxyProtocol: AnyObject { func userIdentity(for userID: String, fallBackToServer: Bool) async -> Result + // MARK: - Live Location + + /// Publishes updates about the current user's own live location beacon info state changes (start/stop) as echoed by the server. + var liveLocationOwnInfoUpdatesPublisher: AnyPublisher { get } + // MARK: - Moderation & Safety func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift index 019744687..c58b22812 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManager.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -21,16 +21,20 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana /// Cached joined room proxies keyed by room ID, kept in sync with the active sessions dictionary. private var activeRoomProxies = [String: JoinedRoomProxyProtocol]() + /// Sessions that have been requested but not yet confirmed by the server echo. + /// Once the server acknowledges the beacon info, sessions are promoted to the persistent store. + private var startingLiveLocationSharingSessionsByRoomID = [String: LiveLocationSession]() + /// Subject used to pipe location updates into the backpressure-aware processing loop. private let locationUpdateSubject = PassthroughSubject() - + /// The most recent location update waiting to be sent. When a send is already in progress, /// new updates overwrite this value so only the latest is sent once the current send completes. private var latestPendingLocation: CLLocationCoordinate2D? - + /// Whether a location send cycle (send + minimum delay) is currently in progress. private var isProcessingLocationUpdate = false - + private var cancellables = Set() private var isUpdatingLocation = false @@ -64,7 +68,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: appSettings.liveLocationMinimumDistanceUpdate) setupSubscriptions() } - + // MARK: - LiveLocationManagerProtocol var hasDisplayedLiveLocationDisclaimer: Bool { @@ -87,7 +91,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana func startLiveLocation(roomID: String, duration: Duration) async -> Result { // Stop any existing session for this room first var didAlreadyStopLocalSession = false - if appSettings.liveLocationSharingTimeoutDatesByRoomID[roomID] != nil { + if appSettings.liveLocationSharingSessionsByRoomID[roomID] != nil + || startingLiveLocationSharingSessionsByRoomID[roomID] != nil { await stopLiveLocation(roomID: roomID) didAlreadyStopLocalSession = true } @@ -104,20 +109,13 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } let result = await roomProxy.startLiveLocationShare(duration: duration) - guard case .success = result else { + guard case .success(let eventID) = result else { MXLog.error("Failed to start live location share in room: \(roomID)") return .failure(.startFailed) } - let timeoutDate = Date().addingTimeInterval(TimeInterval(duration.seconds)) - appSettings.liveLocationSharingTimeoutDatesByRoomID[roomID] = timeoutDate - - if isUpdatingLocation, let lastLocation { - // To make sure the newly started session is in sync with the existing ones, - // we re-send the last location received by the manager. - // Otherwise we would need to wait a distance filtered update. - locationUpdateSubject.send(lastLocation) - } + let expirationDate = Date().addingTimeInterval(TimeInterval(duration.seconds)) + startingLiveLocationSharingSessionsByRoomID[roomID] = LiveLocationSession(eventID: eventID, expirationDate: expirationDate) return .success(()) } @@ -125,7 +123,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana func stopLiveLocation(roomID: String) async { var roomProxy: JoinedRoomProxyProtocol? let cachedRoomProxy = activeRoomProxies[roomID] - appSettings.liveLocationSharingTimeoutDatesByRoomID.removeValue(forKey: roomID) + startingLiveLocationSharingSessionsByRoomID.removeValue(forKey: roomID) + appSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: roomID) if let cachedRoomProxy { roomProxy = cachedRoomProxy @@ -186,12 +185,20 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } .store(in: &cancellables) - appSettings.$liveLocationSharingTimeoutDatesByRoomID + clientProxy.liveLocationOwnInfoUpdatesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] update in + guard let self else { return } + handleBeaconInfoUpdate(update) + } + .store(in: &cancellables) + + appSettings.$liveLocationSharingSessionsByRoomID .removeDuplicates() .sink { [weak self] sessions in guard let self else { return } syncActiveRoomProxies(with: sessions) - + if sessions.isEmpty { self.stopUpdatingLocation() } else { @@ -209,7 +216,32 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana .store(in: &cancellables) } - private func syncActiveRoomProxies(with sessions: [String: Date]) { + private func handleBeaconInfoUpdate(_ update: LiveLocationOwnInfoUpdate) { + // A new beaconInfo has been received in a room with existing active session. + // This is either a new start or a new stop from a different device, so we + // should remove the session from the current local one. + appSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: update.roomID) + + // Instead if we receive a new isLiveUpdate + guard update.isLive else { return } + + // That belongs to a session that is starting in a room and matches the eventID + guard let session = startingLiveLocationSharingSessionsByRoomID[update.roomID], + session.eventID == update.eventID else { + return + } + + // This means the server has echoed the start of the session and we can safely promote it + // to a started session and start sending live locations. + startingLiveLocationSharingSessionsByRoomID.removeValue(forKey: update.roomID) + appSettings.liveLocationSharingSessionsByRoomID[update.roomID] = session + + if isUpdatingLocation, let lastLocation { + locationUpdateSubject.send(lastLocation) + } + } + + private func syncActiveRoomProxies(with sessions: [String: LiveLocationSession]) { // Remove proxies for rooms no longer in the dictionary. let activeRoomIDs = Set(sessions.keys) for roomID in activeRoomProxies.keys where !activeRoomIDs.contains(roomID) { @@ -259,11 +291,11 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana /// discarding any intermediate updates while always keeping the last one. private func processLocationUpdateIfNeeded() { guard !isProcessingLocationUpdate, let location = latestPendingLocation else { return } - guard !appSettings.liveLocationSharingTimeoutDatesByRoomID.isEmpty else { return } - + guard !appSettings.liveLocationSharingSessionsByRoomID.isEmpty else { return } + latestPendingLocation = nil isProcessingLocationUpdate = true - + Task { @MainActor [weak self] in guard let self else { return } @@ -282,13 +314,13 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana processLocationUpdateIfNeeded() } } - + private func sendLocationToActiveRooms(_ coordinate: CLLocationCoordinate2D) async { - let sessions = appSettings.liveLocationSharingTimeoutDatesByRoomID + let sessions = appSettings.liveLocationSharingSessionsByRoomID let geoURI = GeoURI(coordinate: coordinate, uncertainty: nil) - for (roomID, timeoutDate) in sessions { - if Date() >= timeoutDate { + for (roomID, session) in sessions { + if Date() >= session.expirationDate { MXLog.info("Live location session expired for room: \(roomID)") await stopLiveLocation(roomID: roomID) continue @@ -306,7 +338,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana case .failure(let error): switch error { case .liveLocationSessionIsNotActive: - MXLog.error("Failed to send live locatio update to room \(roomID): session not active") + MXLog.error("Failed to send live location update to room \(roomID): session not active") await stopLiveLocation(roomID: roomID) default: MXLog.error("Failed to send live location update to room \(roomID): \(error)") @@ -329,7 +361,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } private func stopAllSessions() { - let roomIDs = Array(appSettings.liveLocationSharingTimeoutDatesByRoomID.keys) + let roomIDs = Array(Set(appSettings.liveLocationSharingSessionsByRoomID.keys) + .union(startingLiveLocationSharingSessionsByRoomID.keys)) Task { [weak self] in guard let self else { return } for roomID in roomIDs { diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 0ba8300ec..9d480fc97 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -758,10 +758,10 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { await RoomLiveLocationService(liveLocationsObserver: room.liveLocationsObserver()) } - func startLiveLocationShare(duration: Duration) async -> Result { + func startLiveLocationShare(duration: Duration) async -> Result { do { - try await room.startLiveLocationShare(durationMillis: UInt64(duration.seconds * 1000)) - return .success(()) + let eventID = try await room.startLiveLocationShare(durationMillis: UInt64(duration.seconds * 1000)) + return .success(eventID) } catch { MXLog.error("Failed starting live location share with error: \(error)") return .failure(.sdkError(error)) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 5053422e2..6d67a1f5f 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -200,7 +200,7 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func makeLiveLocationService() async -> RoomLiveLocationServiceProtocol - func startLiveLocationShare(duration: Duration) async -> Result + func startLiveLocationShare(duration: Duration) async -> Result func sendLiveLocation(geoURI: GeoURI) async -> Result func stopLiveLocationShare() async -> Result } diff --git a/UnitTests/Sources/LiveLocationManagerTests.swift b/UnitTests/Sources/LiveLocationManagerTests.swift index afccdd7aa..eee05c3c0 100644 --- a/UnitTests/Sources/LiveLocationManagerTests.swift +++ b/UnitTests/Sources/LiveLocationManagerTests.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine import CoreLocation @testable import ElementX import Foundation @@ -16,6 +17,7 @@ final class LiveLocationManagerTests { private var locationManagerMock: CLLocationManagerMock! private var manager: LiveLocationManager! private var appSettings: AppSettings! + private var beaconInfoSubject: PassthroughSubject! init() { AppSettings.resetAllSettings() @@ -40,14 +42,18 @@ final class LiveLocationManagerTests { } roomProxy.startLiveLocationShareDurationClosure = { _ in callOrder.append("start") - return .success(()) + return .success("$event:matrix.org") } let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) try result.get() #expect(callOrder == ["stop", "start"]) - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] != nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) + + try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") + + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) #expect(locationManagerMock.startUpdatingLocationCalled) } @@ -56,7 +62,7 @@ final class LiveLocationManagerTests { setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } - appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] = Date().addingTimeInterval(300) + appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] = LiveLocationSession(eventID: "$old_event:matrix.org", expirationDate: Date().addingTimeInterval(300)) var callOrder: [String] = [] roomProxy.stopLiveLocationShareClosure = { @@ -65,14 +71,17 @@ final class LiveLocationManagerTests { } roomProxy.startLiveLocationShareDurationClosure = { _ in callOrder.append("start") - return .success(()) + return .success("$event:matrix.org") } let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(600)) try result.get() #expect(callOrder == ["stop", "start"]) - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] != nil) + + try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") + + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) } @Test @@ -81,12 +90,12 @@ final class LiveLocationManagerTests { let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } - appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] = Date().addingTimeInterval(300) + appSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) _ = await manager.startLiveLocation(roomID: "!room1:matrix.org", duration: .seconds(300)) #expect(roomProxy.stopLiveLocationShareCalled) - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] != nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] != nil) } @Test @@ -97,7 +106,7 @@ final class LiveLocationManagerTests { let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) #expect(throws: LiveLocationManagerError.roomNotJoined) { try result.get() } - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] == nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) } @Test @@ -110,7 +119,7 @@ final class LiveLocationManagerTests { let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) #expect(throws: LiveLocationManagerError.startFailed) { try result.get() } - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] == nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) } @Test @@ -124,11 +133,14 @@ final class LiveLocationManagerTests { _ = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: duration) let afterStart = Date() - let storedTimeout = appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] + try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") + + let storedSession = try #require(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"]) let expectedMinTimeout = beforeStart.addingTimeInterval(TimeInterval(duration.seconds)) let expectedMaxTimeout = afterStart.addingTimeInterval(TimeInterval(duration.seconds)) - try #expect((expectedMinTimeout...expectedMaxTimeout).contains(#require(storedTimeout))) + #expect((expectedMinTimeout...expectedMaxTimeout).contains(storedSession.expirationDate)) + #expect(storedSession.eventID == "$event:matrix.org") } // MARK: - stopLiveLocation @@ -138,12 +150,12 @@ final class LiveLocationManagerTests { setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } - appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] = Date().addingTimeInterval(300) + appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) await manager.stopLiveLocation(roomID: "!room:matrix.org") #expect(roomProxy.stopLiveLocationShareCalled) - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] == nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) // Setting the timeout date above starts tracking; removing it stops tracking. #expect(locationManagerMock.startUpdatingLocationCalled) #expect(locationManagerMock.stopUpdatingLocationCalled) @@ -165,15 +177,34 @@ final class LiveLocationManagerTests { setUp() let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } - appSettings.liveLocationSharingTimeoutDatesByRoomID["!room1:matrix.org"] = Date().addingTimeInterval(300) - appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] = Date().addingTimeInterval(300) + appSettings.liveLocationSharingSessionsByRoomID["!room1:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) + appSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) await manager.stopLiveLocation(roomID: "!room1:matrix.org") - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room1:matrix.org"] == nil) - #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] != nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room1:matrix.org"] == nil) + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] != nil) } + // MARK: - Beacon info updates + + @Test + func beaconInfoUpdateFromAnotherDeviceRemovesActiveSession() async throws { + setUp() + let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") + clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } + + try await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)).get() + try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) + + let deferred = deferFulfillment(appSettings.$liveLocationSharingSessionsByRoomID) { $0["!room:matrix.org"] == nil } + beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: "!room:matrix.org", eventID: "$external_event:matrix.org", isLive: true)) + try await deferred.fulfill() + + #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) + } + // MARK: - Reduced accuracy @Test @@ -185,6 +216,8 @@ final class LiveLocationManagerTests { let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) try result.get() + try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") + #expect(locationManagerMock.startUpdatingLocationCalled) #expect(locationManagerMock.desiredAccuracy == kCLLocationAccuracyReduced) @@ -197,7 +230,7 @@ final class LiveLocationManagerTests { private func makeRoomProxy(roomID: String) -> JoinedRoomProxyMock { let roomProxy = JoinedRoomProxyMock(.init(id: roomID)) - roomProxy.startLiveLocationShareDurationReturnValue = .success(()) + roomProxy.startLiveLocationShareDurationReturnValue = .success("$event:matrix.org") roomProxy.stopLiveLocationShareReturnValue = .success(()) return roomProxy } @@ -205,7 +238,15 @@ final class LiveLocationManagerTests { private func setUp(accuracyAuthorization: CLAccuracyAuthorization = .fullAccuracy) { appSettings = AppSettings() clientProxy = ClientProxyMock(.init()) + beaconInfoSubject = PassthroughSubject() + clientProxy.liveLocationOwnInfoUpdatesPublisher = beaconInfoSubject.eraseToAnyPublisher() locationManagerMock = CLLocationManagerMock(.init(accuracyAuthorization: accuracyAuthorization)) manager = LiveLocationManager(clientProxy: clientProxy, appSettings: appSettings, locationManager: locationManagerMock) } + + private func simulateBeaconEcho(roomID: String, eventID: String) async throws { + let deferred = deferFulfillment(appSettings.$liveLocationSharingSessionsByRoomID) { $0[roomID] != nil } + beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: roomID, eventID: eventID, isLive: true)) + try await deferred.fulfill() + } }