diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 42d78aa17..3e460688b 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -80,6 +80,7 @@ final class AppSettings { case focusEventOnNotificationTap case linkNewDeviceEnabled case liveLocationSharingEnabled + case liveLocationSharingTimeoutDatesByRoomID case floatingTimelineDateEnabled // Doug's tweaks 🔧 @@ -349,6 +350,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.hasRequestedLocationAlwaysLocationAuthorization, defaultValue: false, storageType: .userDefaults(store)) var hasRequestedLocationAlwaysLocationAuthorization + @UserPreference(key: UserDefaultsKeys.liveLocationSharingTimeoutDatesByRoomID, defaultValue: [String: Date](), storageType: .userDefaults(store)) + var liveLocationSharingTimeoutDatesByRoomID + @UserPreference(key: UserDefaultsKeys.frequentlyUsedSystemEmojis, defaultValue: [FrequentlyUsedEmoji](), storageType: .userDefaults(store)) var frequentlyUsedSystemEmojis diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 9cbb227b7..5c3067b53 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -10062,6 +10062,210 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { return clearDraftThreadRootEventIDReturnValue } } + //MARK: - startLiveLocationShare + + var startLiveLocationShareDurationMillisUnderlyingCallsCount = 0 + var startLiveLocationShareDurationMillisCallsCount: Int { + get { + if Thread.isMainThread { + return startLiveLocationShareDurationMillisUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = startLiveLocationShareDurationMillisUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + startLiveLocationShareDurationMillisUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + startLiveLocationShareDurationMillisUnderlyingCallsCount = newValue + } + } + } + } + var startLiveLocationShareDurationMillisCalled: Bool { + return startLiveLocationShareDurationMillisCallsCount > 0 + } + var startLiveLocationShareDurationMillisReceivedDurationMillis: UInt64? + var startLiveLocationShareDurationMillisReceivedInvocations: [UInt64] = [] + + var startLiveLocationShareDurationMillisUnderlyingReturnValue: Result! + var startLiveLocationShareDurationMillisReturnValue: Result! { + get { + if Thread.isMainThread { + return startLiveLocationShareDurationMillisUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = startLiveLocationShareDurationMillisUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + startLiveLocationShareDurationMillisUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + startLiveLocationShareDurationMillisUnderlyingReturnValue = newValue + } + } + } + } + var startLiveLocationShareDurationMillisClosure: ((UInt64) async -> Result)? + + func startLiveLocationShare(durationMillis: UInt64) async -> Result { + startLiveLocationShareDurationMillisCallsCount += 1 + startLiveLocationShareDurationMillisReceivedDurationMillis = durationMillis + DispatchQueue.main.async { + self.startLiveLocationShareDurationMillisReceivedInvocations.append(durationMillis) + } + if let startLiveLocationShareDurationMillisClosure = startLiveLocationShareDurationMillisClosure { + return await startLiveLocationShareDurationMillisClosure(durationMillis) + } else { + return startLiveLocationShareDurationMillisReturnValue + } + } + //MARK: - sendLiveLocation + + var sendLiveLocationGeoURIUnderlyingCallsCount = 0 + var sendLiveLocationGeoURICallsCount: Int { + get { + if Thread.isMainThread { + return sendLiveLocationGeoURIUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = sendLiveLocationGeoURIUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + sendLiveLocationGeoURIUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + sendLiveLocationGeoURIUnderlyingCallsCount = newValue + } + } + } + } + var sendLiveLocationGeoURICalled: Bool { + return sendLiveLocationGeoURICallsCount > 0 + } + var sendLiveLocationGeoURIReceivedGeoURI: GeoURI? + var sendLiveLocationGeoURIReceivedInvocations: [GeoURI] = [] + + var sendLiveLocationGeoURIUnderlyingReturnValue: Result! + var sendLiveLocationGeoURIReturnValue: Result! { + get { + if Thread.isMainThread { + return sendLiveLocationGeoURIUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = sendLiveLocationGeoURIUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + sendLiveLocationGeoURIUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + sendLiveLocationGeoURIUnderlyingReturnValue = newValue + } + } + } + } + var sendLiveLocationGeoURIClosure: ((GeoURI) async -> Result)? + + func sendLiveLocation(geoURI: GeoURI) async -> Result { + sendLiveLocationGeoURICallsCount += 1 + sendLiveLocationGeoURIReceivedGeoURI = geoURI + DispatchQueue.main.async { + self.sendLiveLocationGeoURIReceivedInvocations.append(geoURI) + } + if let sendLiveLocationGeoURIClosure = sendLiveLocationGeoURIClosure { + return await sendLiveLocationGeoURIClosure(geoURI) + } else { + return sendLiveLocationGeoURIReturnValue + } + } + //MARK: - stopLiveLocationShare + + var stopLiveLocationShareUnderlyingCallsCount = 0 + var stopLiveLocationShareCallsCount: Int { + get { + if Thread.isMainThread { + return stopLiveLocationShareUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = stopLiveLocationShareUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + stopLiveLocationShareUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + stopLiveLocationShareUnderlyingCallsCount = newValue + } + } + } + } + var stopLiveLocationShareCalled: Bool { + return stopLiveLocationShareCallsCount > 0 + } + + var stopLiveLocationShareUnderlyingReturnValue: Result! + var stopLiveLocationShareReturnValue: Result! { + get { + if Thread.isMainThread { + return stopLiveLocationShareUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = stopLiveLocationShareUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + stopLiveLocationShareUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + stopLiveLocationShareUnderlyingReturnValue = newValue + } + } + } + } + var stopLiveLocationShareClosure: (() async -> Result)? + + func stopLiveLocationShare() async -> Result { + stopLiveLocationShareCallsCount += 1 + if let stopLiveLocationShareClosure = stopLiveLocationShareClosure { + return await stopLiveLocationShareClosure() + } else { + return stopLiveLocationShareReturnValue + } + } } class KeychainControllerMock: KeychainControllerProtocol, @unchecked Sendable { @@ -11236,6 +11440,117 @@ class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable return requestAlwaysAuthorizationIfPossibleReturnValue } } + //MARK: - startLiveLocation + + var startLiveLocationRoomIDDurationMillisUnderlyingCallsCount = 0 + var startLiveLocationRoomIDDurationMillisCallsCount: Int { + get { + if Thread.isMainThread { + return startLiveLocationRoomIDDurationMillisUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = startLiveLocationRoomIDDurationMillisUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + startLiveLocationRoomIDDurationMillisUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + startLiveLocationRoomIDDurationMillisUnderlyingCallsCount = newValue + } + } + } + } + var startLiveLocationRoomIDDurationMillisCalled: Bool { + return startLiveLocationRoomIDDurationMillisCallsCount > 0 + } + var startLiveLocationRoomIDDurationMillisReceivedArguments: (roomID: String, durationMillis: UInt64)? + var startLiveLocationRoomIDDurationMillisReceivedInvocations: [(roomID: String, durationMillis: UInt64)] = [] + + var startLiveLocationRoomIDDurationMillisUnderlyingReturnValue: Result! + var startLiveLocationRoomIDDurationMillisReturnValue: Result! { + get { + if Thread.isMainThread { + return startLiveLocationRoomIDDurationMillisUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = startLiveLocationRoomIDDurationMillisUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + startLiveLocationRoomIDDurationMillisUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + startLiveLocationRoomIDDurationMillisUnderlyingReturnValue = newValue + } + } + } + } + var startLiveLocationRoomIDDurationMillisClosure: ((String, UInt64) async -> Result)? + + func startLiveLocation(roomID: String, durationMillis: UInt64) async -> Result { + startLiveLocationRoomIDDurationMillisCallsCount += 1 + startLiveLocationRoomIDDurationMillisReceivedArguments = (roomID: roomID, durationMillis: durationMillis) + DispatchQueue.main.async { + self.startLiveLocationRoomIDDurationMillisReceivedInvocations.append((roomID: roomID, durationMillis: durationMillis)) + } + if let startLiveLocationRoomIDDurationMillisClosure = startLiveLocationRoomIDDurationMillisClosure { + return await startLiveLocationRoomIDDurationMillisClosure(roomID, durationMillis) + } else { + return startLiveLocationRoomIDDurationMillisReturnValue + } + } + //MARK: - stopLiveLocation + + var stopLiveLocationRoomIDUnderlyingCallsCount = 0 + var stopLiveLocationRoomIDCallsCount: Int { + get { + if Thread.isMainThread { + return stopLiveLocationRoomIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = stopLiveLocationRoomIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + stopLiveLocationRoomIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + stopLiveLocationRoomIDUnderlyingCallsCount = newValue + } + } + } + } + var stopLiveLocationRoomIDCalled: Bool { + return stopLiveLocationRoomIDCallsCount > 0 + } + var stopLiveLocationRoomIDReceivedRoomID: String? + var stopLiveLocationRoomIDReceivedInvocations: [String] = [] + var stopLiveLocationRoomIDClosure: ((String) async -> Void)? + + func stopLiveLocation(roomID: String) async { + stopLiveLocationRoomIDCallsCount += 1 + stopLiveLocationRoomIDReceivedRoomID = roomID + DispatchQueue.main.async { + self.stopLiveLocationRoomIDReceivedInvocations.append(roomID) + } + await stopLiveLocationRoomIDClosure?(roomID) + } } class MediaLoaderMock: MediaLoaderProtocol, @unchecked Sendable { diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift index 0ad63e0eb..ecbff202c 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManager.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -15,6 +15,15 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana private let authorizationStatusSubject: CurrentValueSubject + /// Cached joined room proxies keyed by room ID, kept in sync with the active sessions dictionary. + private var activeRoomProxies = [String: JoinedRoomProxyProtocol]() + + /// The running task that iterates over live location updates. + @CancellableTask + private var locationUpdatesTask: Task? + + private var cancellables = Set() + var authorizationStatus: CurrentValuePublisher { authorizationStatusSubject.asCurrentValuePublisher() } @@ -33,6 +42,11 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana super.init() locationManager.delegate = self + locationManager.allowsBackgroundLocationUpdates = true + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.pausesLocationUpdatesAutomatically = false + + setupSubscriptions() } // MARK: - LiveLocationManagerProtocol @@ -45,6 +59,39 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana return true } + func startLiveLocation(roomID: String, durationMillis: UInt64) async -> Result { + guard case .joined(let roomProxy) = await clientProxy.roomForIdentifier(roomID) else { + MXLog.error("Failed to resolve joined room for identifier: \(roomID)") + return .failure(.roomNotJoined) + } + + let result = await roomProxy.startLiveLocationShare(durationMillis: durationMillis) + + guard case .success = result else { + MXLog.error("Failed to start live location share in room: \(roomID)") + return .failure(.startFailed) + } + + let timeoutDate = Date().addingTimeInterval(TimeInterval(durationMillis) / 1000.0) + appSettings.liveLocationSharingTimeoutDatesByRoomID[roomID] = timeoutDate + + return .success(()) + } + + func stopLiveLocation(roomID: String) async { + // Best effort: send the stop event to the room regardless of tracking state. + if let roomProxy = await resolveRoomProxy(for: roomID) { + let result = await roomProxy.stopLiveLocationShare() + if case .failure(let error) = result { + MXLog.error("Failed to stop live location share in room \(roomID): \(error)") + } + } + + // Always clean up locally. + appSettings.liveLocationSharingTimeoutDatesByRoomID.removeValue(forKey: roomID) + activeRoomProxies.removeValue(forKey: roomID) + } + // MARK: - CLLocationManagerDelegate func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { @@ -53,6 +100,114 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana if manager.authorizationStatus == .notDetermined { appSettings.hasRequestedLocationAlwaysLocationAuthorization = false } + + // If authorization was revoked, stop all active sessions. + if manager.authorizationStatus != .authorizedAlways { + stopAllSessions() + } + authorizationStatusSubject.send(manager.authorizationStatus) } + + // MARK: - Private + + private func setupSubscriptions() { + appSettings.$liveLocationSharingTimeoutDatesByRoomID + .removeDuplicates() + .sink { [weak self] sessions in + guard let self else { return } + syncActiveRoomProxies(with: sessions) + + if sessions.isEmpty { + locationUpdatesTask = nil + } else { + startLocationUpdatesIfNeeded() + } + } + .store(in: &cancellables) + + appSettings.$liveLocationSharingEnabled + .filter { !$0 } + .sink { [weak self] _ in + guard let self else { return } + appSettings.liveLocationSharingTimeoutDatesByRoomID.removeAll() + activeRoomProxies.removeAll() + locationUpdatesTask = nil + } + .store(in: &cancellables) + } + + private func syncActiveRoomProxies(with sessions: [String: Date]) { + // Remove proxies for rooms no longer in the dictionary. + let activeRoomIDs = Set(sessions.keys) + for roomID in activeRoomProxies.keys where !activeRoomIDs.contains(roomID) { + activeRoomProxies.removeValue(forKey: roomID) + } + } + + private func startLocationUpdatesIfNeeded() { + guard locationUpdatesTask == nil else { return } + + locationUpdatesTask = Task { [weak self] in + do { + for try await update in CLLocationUpdate.liveUpdates() { + guard let self, !Task.isCancelled else { break } + + await self.sendLocationToActiveRooms(update) + } + } catch { + MXLog.error("Live location updates failed with error: \(error)") + self?.stopAllSessions() + } + } + } + + private func sendLocationToActiveRooms(_ update: CLLocationUpdate) async { + let sessions = appSettings.liveLocationSharingTimeoutDatesByRoomID + let geoURI = update.location.map { GeoURI(coordinate: $0.coordinate, uncertainty: $0.horizontalAccuracy) } + + for (roomID, timeoutDate) in sessions { + if Date() >= timeoutDate { + MXLog.info("Live location session expired for room: \(roomID)") + await stopLiveLocation(roomID: roomID) + continue + } + + guard let geoURI else { continue } + + let roomProxy = await resolveRoomProxy(for: roomID) + guard let roomProxy else { + MXLog.error("Failed to resolve room proxy for live location update in room: \(roomID)") + continue + } + + let result = await roomProxy.sendLiveLocation(geoURI: geoURI) + if case .failure(let error) = result { + MXLog.error("Failed to send live location update to room \(roomID): \(error)") + } + } + } + + private func resolveRoomProxy(for roomID: String) async -> JoinedRoomProxyProtocol? { + if let cached = activeRoomProxies[roomID] { + return cached + } + + guard case .joined(let roomProxy) = await clientProxy.roomForIdentifier(roomID) else { + return nil + } + + activeRoomProxies[roomID] = roomProxy + return roomProxy + } + + private func stopAllSessions() { + let roomIDs = Array(appSettings.liveLocationSharingTimeoutDatesByRoomID.keys) + Task { [weak self] in + guard let self else { return } + for roomID in roomIDs { + await stopLiveLocation(roomID: roomID) + } + } + } } diff --git a/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift b/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift index 4e286c389..3a339c30e 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift @@ -9,6 +9,11 @@ import Combine import CoreLocation import Foundation +enum LiveLocationManagerError: Error { + case roomNotJoined + case startFailed +} + // sourcery: AutoMockable protocol LiveLocationManagerProtocol: AnyObject { /// Publishes the current location authorization status. @@ -20,4 +25,18 @@ protocol LiveLocationManagerProtocol: AnyObject { /// `false` if the request was already made before and iOS would silently ignore it. @discardableResult func requestAlwaysAuthorizationIfPossible() -> Bool + + /// Starts sharing live location in a room. + /// + /// - Parameters: + /// - roomID: The identifier of the room to share live location in. + /// - durationMillis: The duration in milliseconds for how long the live location should be shared. + func startLiveLocation(roomID: String, durationMillis: UInt64) async -> Result + + /// Stops sharing live location in a room. + /// + /// Sends a stop event to the room (best effort) and removes it from the tracked sessions. + /// Can also be used to stop a live location share started by another device. + /// - Parameter roomID: The identifier of the room to stop sharing live location in. + func stopLiveLocation(roomID: String) async } diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 913060eb0..60451f432 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -751,6 +751,38 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { return .failure(.sdkError(error)) } } + + // MARK: - Live Location + + func startLiveLocationShare(durationMillis: UInt64) async -> Result { + do { + try await room.startLiveLocationShare(durationMillis: durationMillis) + return .success(()) + } catch { + MXLog.error("Failed starting live location share with error: \(error)") + return .failure(.sdkError(error)) + } + } + + func sendLiveLocation(geoURI: GeoURI) async -> Result { + do { + try await room.sendLiveLocation(geoUri: geoURI.string) + return .success(()) + } catch { + MXLog.error("Failed sending live location with error: \(error)") + return .failure(.sdkError(error)) + } + } + + func stopLiveLocationShare() async -> Result { + do { + try await room.stopLiveLocationShare() + return .success(()) + } catch { + MXLog.error("Failed stopping live location share with error: \(error)") + return .failure(.sdkError(error)) + } + } // MARK: - Private diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 68a1e08a1..9ed941670 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -194,6 +194,12 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func saveDraft(_ draft: ComposerDraft, threadRootEventID: String?) async -> Result func loadDraft(threadRootEventID: String?) async -> Result func clearDraft(threadRootEventID: String?) async -> Result + + // MARK: - Live Location + + func startLiveLocationShare(durationMillis: UInt64) async -> Result + func sendLiveLocation(geoURI: GeoURI) async -> Result + func stopLiveLocationShare() async -> Result } extension JoinedRoomProxyProtocol {