diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 08163acce..44ed83af0 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2209,6 +2209,11 @@ class CLLocationManagerMock: CLLocationManagerProtocol, @unchecked Sendable { set(value) { underlyingDistanceFilter = value } } var underlyingDistanceFilter: CLLocationDistance! + var pausesLocationUpdatesAutomatically: Bool { + get { return underlyingPausesLocationUpdatesAutomatically } + set(value) { underlyingPausesLocationUpdatesAutomatically = value } + } + var underlyingPausesLocationUpdatesAutomatically: Bool! var authorizationStatus: CLAuthorizationStatus { get { return underlyingAuthorizationStatus } set(value) { underlyingAuthorizationStatus = value } @@ -2325,76 +2330,6 @@ class CLLocationManagerMock: CLLocationManagerProtocol, @unchecked Sendable { stopUpdatingLocationCallsCount += 1 stopUpdatingLocationClosure?() } - //MARK: - startMonitoringSignificantLocationChanges - - var startMonitoringSignificantLocationChangesUnderlyingCallsCount = 0 - var startMonitoringSignificantLocationChangesCallsCount: Int { - get { - if Thread.isMainThread { - return startMonitoringSignificantLocationChangesUnderlyingCallsCount - } else { - var returnValue: Int? = nil - DispatchQueue.main.sync { - returnValue = startMonitoringSignificantLocationChangesUnderlyingCallsCount - } - - return returnValue! - } - } - set { - if Thread.isMainThread { - startMonitoringSignificantLocationChangesUnderlyingCallsCount = newValue - } else { - DispatchQueue.main.sync { - startMonitoringSignificantLocationChangesUnderlyingCallsCount = newValue - } - } - } - } - var startMonitoringSignificantLocationChangesCalled: Bool { - return startMonitoringSignificantLocationChangesCallsCount > 0 - } - var startMonitoringSignificantLocationChangesClosure: (() -> Void)? - - func startMonitoringSignificantLocationChanges() { - startMonitoringSignificantLocationChangesCallsCount += 1 - startMonitoringSignificantLocationChangesClosure?() - } - //MARK: - stopMonitoringSignificantLocationChanges - - var stopMonitoringSignificantLocationChangesUnderlyingCallsCount = 0 - var stopMonitoringSignificantLocationChangesCallsCount: Int { - get { - if Thread.isMainThread { - return stopMonitoringSignificantLocationChangesUnderlyingCallsCount - } else { - var returnValue: Int? = nil - DispatchQueue.main.sync { - returnValue = stopMonitoringSignificantLocationChangesUnderlyingCallsCount - } - - return returnValue! - } - } - set { - if Thread.isMainThread { - stopMonitoringSignificantLocationChangesUnderlyingCallsCount = newValue - } else { - DispatchQueue.main.sync { - stopMonitoringSignificantLocationChangesUnderlyingCallsCount = newValue - } - } - } - } - var stopMonitoringSignificantLocationChangesCalled: Bool { - return stopMonitoringSignificantLocationChangesCallsCount > 0 - } - var stopMonitoringSignificantLocationChangesClosure: (() -> Void)? - - func stopMonitoringSignificantLocationChanges() { - stopMonitoringSignificantLocationChangesCallsCount += 1 - stopMonitoringSignificantLocationChangesClosure?() - } } class CXProviderMock: CXProviderProtocol, @unchecked Sendable { diff --git a/ElementX/Sources/Services/Location/CLLocationManagerProtocol.swift b/ElementX/Sources/Services/Location/CLLocationManagerProtocol.swift index ff80c3cb9..c22d797e4 100644 --- a/ElementX/Sources/Services/Location/CLLocationManagerProtocol.swift +++ b/ElementX/Sources/Services/Location/CLLocationManagerProtocol.swift @@ -15,14 +15,13 @@ protocol CLLocationManagerProtocol: AnyObject { var showsBackgroundLocationIndicator: Bool { get set } var desiredAccuracy: CLLocationAccuracy { get set } var distanceFilter: CLLocationDistance { get set } + var pausesLocationUpdatesAutomatically: Bool { get set } var authorizationStatus: CLAuthorizationStatus { get } var accuracyAuthorization: CLAccuracyAuthorization { get } func requestAlwaysAuthorization() func startUpdatingLocation() func stopUpdatingLocation() - func startMonitoringSignificantLocationChanges() - func stopMonitoringSignificantLocationChanges() } extension CLLocationManager: CLLocationManagerProtocol { } diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift index 49f4397a4..f291d8785 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManager.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -9,12 +9,6 @@ import Combine import CoreLocation class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationManagerDelegate { - enum LiveState { - case full - case limited - case off - } - private let clientProxy: ClientProxyProtocol private let locationManager: CLLocationManagerProtocol private let appSettings: AppSettings @@ -32,7 +26,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana private var cancellables = Set() - private var liveState = LiveState.off + private var isUpdatingLocation = false @MainActor init(clientProxy: ClientProxyProtocol, @@ -52,7 +46,13 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana self.locationManager.delegate = self self.locationManager.allowsBackgroundLocationUpdates = true self.locationManager.showsBackgroundLocationIndicator = true - setupMinimumDistance(appSettings.liveLocationMinimumDistanceUpdate) + + // Since unpausing location updates is not trivial, let's always keep the location updates running + // The distance filtering will already take care of not sending updates when not required. + // https://developer.apple.com/documentation/corelocation/cllocationmanager/pauseslocationupdatesautomatically + self.locationManager.pausesLocationUpdatesAutomatically = false + + setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: appSettings.liveLocationMinimumDistanceUpdate) setupSubscriptions() } @@ -125,18 +125,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana stopAllSessions() } - switch (liveState, manager.accuracyAuthorization) { - // If accuracy authorization changed while updates are active, start and stop to switch update method. - case (.full, .reducedAccuracy), (.limited, .fullAccuracy): - stopUpdatingLocation() - if manager.accuracyAuthorization == .fullAccuracy { - // The system has forced reduced desired accuracy so we need to restore the desired value by the user. - setupMinimumDistance(appSettings.liveLocationMinimumDistanceUpdate) - } - startUpdatingLocation() - default: - break - } + // Accuracy authorization may have changed, reapply new accuracy settings. + setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: appSettings.liveLocationMinimumDistanceUpdate) authorizationStatusSubject.send(manager.authorizationStatus) } @@ -194,8 +184,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana appSettings.$liveLocationMinimumDistanceUpdate .removeDuplicates() .debounce(for: .seconds(1), scheduler: DispatchQueue.main) - .sink { [weak self] minimumDistance in - self?.setupMinimumDistance(minimumDistance) + .sink { [weak self] newValue in + self?.setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: newValue) } .store(in: &cancellables) } @@ -209,42 +199,36 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } /// Sets up the distance filter and the most optimal accuracy given the minimum distance to save battery. - private func setupMinimumDistance(_ minimumDistance: Int) { - switch minimumDistance { - case 0..<10: - locationManager.desiredAccuracy = kCLLocationAccuracyBest - case 10..<100: - locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters - default: - locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + private func setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: Int) { + if locationManager.accuracyAuthorization == .fullAccuracy { + switch minimumDistance { + case 0..<10: + locationManager.desiredAccuracy = kCLLocationAccuracyBest + case 10..<100: + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + default: + locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + } else { + locationManager.desiredAccuracy = kCLLocationAccuracyReduced } locationManager.distanceFilter = CLLocationDistance(minimumDistance) } private func startUpdatingLocation() { - guard liveState == .off else { return } + guard !isUpdatingLocation else { return } - if locationManager.accuracyAuthorization == .fullAccuracy { - MXLog.info("Starting live location updates with full accuracy") - liveState = .full - locationManager.startUpdatingLocation() - } else { - MXLog.info("Starting live location updates with significant changes (reduced accuracy)") - liveState = .limited - locationManager.startMonitoringSignificantLocationChanges() - } + MXLog.info("Starting live location updates") + isUpdatingLocation = true + locationManager.startUpdatingLocation() } private func stopUpdatingLocation() { - if liveState == .full { - MXLog.info("Stopping live location updates (full accuracy)") - locationManager.stopUpdatingLocation() - } else if liveState == .limited { - MXLog.info("Stopping live location updates (reduced accuracy)") - locationManager.stopMonitoringSignificantLocationChanges() - } + guard isUpdatingLocation else { return } - liveState = .off + MXLog.info("Stopping live location updates") + locationManager.stopUpdatingLocation() + isUpdatingLocation = false } private func sendLocationToActiveRooms(_ coordinate: CLLocationCoordinate2D) async { diff --git a/UnitTests/Sources/LiveLocationManagerTests.swift b/UnitTests/Sources/LiveLocationManagerTests.swift index 9272c83ba..1a46dae26 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 CoreLocation @testable import ElementX import Foundation import Testing @@ -14,15 +15,10 @@ final class LiveLocationManagerTests { private var clientProxy: ClientProxyMock! private var locationManagerMock: CLLocationManagerMock! private var manager: LiveLocationManager! - - private let appSettings: AppSettings + private var appSettings: AppSettings! init() { AppSettings.resetAllSettings() - appSettings = AppSettings() - clientProxy = ClientProxyMock(.init()) - locationManagerMock = CLLocationManagerMock(.init()) - manager = LiveLocationManager(clientProxy: clientProxy, appSettings: appSettings, locationManager: locationManagerMock) } deinit { @@ -33,6 +29,7 @@ final class LiveLocationManagerTests { @Test func startLiveLocationWithNoExistingSession() async throws { + setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } @@ -43,11 +40,11 @@ final class LiveLocationManagerTests { #expect(!roomProxy.stopLiveLocationShareCalled) #expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] != nil) #expect(locationManagerMock.startUpdatingLocationCalled) - #expect(!locationManagerMock.startMonitoringSignificantLocationChangesCalled) } @Test func startLiveLocationWithExistingSessionStopsItFirst() async throws { + setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] = Date().addingTimeInterval(300) @@ -71,6 +68,7 @@ final class LiveLocationManagerTests { @Test func startLiveLocationDoesNotStopSessionForOtherRoom() async { + setUp() let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } @@ -84,6 +82,7 @@ final class LiveLocationManagerTests { @Test func startLiveLocationWhenRoomNotJoined() async { + setUp() clientProxy.roomForIdentifierClosure = { _ in nil } let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) @@ -94,6 +93,7 @@ final class LiveLocationManagerTests { @Test func startLiveLocationWhenStartShareFails() async { + setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") roomProxy.startLiveLocationShareDurationReturnValue = .failure(.sdkError(RoomProxyMockError.generic)) clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } @@ -106,6 +106,7 @@ final class LiveLocationManagerTests { @Test func startLiveLocationStoresTimeoutDate() async throws { + setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } @@ -125,6 +126,7 @@ final class LiveLocationManagerTests { @Test func stopLiveLocationWhenSessionExists() async { + setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] = Date().addingTimeInterval(300) @@ -136,11 +138,11 @@ final class LiveLocationManagerTests { // Setting the timeout date above starts tracking; removing it stops tracking. #expect(locationManagerMock.startUpdatingLocationCalled) #expect(locationManagerMock.stopUpdatingLocationCalled) - #expect(!locationManagerMock.stopMonitoringSignificantLocationChangesCalled) } @Test func stopLiveLocationWhenNoSession() async { + setUp() let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } @@ -151,6 +153,7 @@ final class LiveLocationManagerTests { @Test func stopLiveLocationDoesNotRemoveOtherSessions() async { + setUp() let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } appSettings.liveLocationSharingTimeoutDatesByRoomID["!room1:matrix.org"] = Date().addingTimeInterval(300) @@ -166,20 +169,19 @@ final class LiveLocationManagerTests { @Test func startLiveLocationInReducedAccuracyMode() async throws { - locationManagerMock.underlyingAccuracyAuthorization = .reducedAccuracy + setUp(accuracyAuthorization: .reducedAccuracy) let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) try result.get() - #expect(locationManagerMock.startMonitoringSignificantLocationChangesCalled) - #expect(!locationManagerMock.startUpdatingLocationCalled) + #expect(locationManagerMock.startUpdatingLocationCalled) + #expect(locationManagerMock.desiredAccuracy == kCLLocationAccuracyReduced) await manager.stopLiveLocation(roomID: "!room:matrix.org") - #expect(locationManagerMock.stopMonitoringSignificantLocationChangesCalled) - #expect(!locationManagerMock.stopUpdatingLocationCalled) + #expect(locationManagerMock.stopUpdatingLocationCalled) } // MARK: - Private @@ -190,4 +192,11 @@ final class LiveLocationManagerTests { roomProxy.stopLiveLocationShareReturnValue = .success(()) return roomProxy } + + private func setUp(accuracyAuthorization: CLAccuracyAuthorization = .fullAccuracy) { + appSettings = AppSettings() + clientProxy = ClientProxyMock(.init()) + locationManagerMock = CLLocationManagerMock(.init(accuracyAuthorization: accuracyAuthorization)) + manager = LiveLocationManager(clientProxy: clientProxy, appSettings: appSettings, locationManager: locationManagerMock) + } }