diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3a008868d..0e74872f7 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -851,6 +851,7 @@ 9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; }; 92720AB0DA9AB5EEF1DAF56B /* SecureBackupLogoutConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */; }; 9278EC51D24E57445B290521 /* AudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */; }; + 92919B6522B26B2681232EAC /* LiveLocationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5DF0E888F66652F8C4CEC5 /* LiveLocationManagerMock.swift */; }; 9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */; }; 92A0309B6F490B86C10B603B /* accountsV2 in Resources */ = {isa = PBXBuildFile; fileRef = 2A7BE2B89310058659E6F459 /* accountsV2 */; }; 92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */; }; @@ -1636,6 +1637,7 @@ 0AE4B7AD74E402BBC46A801A /* LeaveSpaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceView.swift; sourceTree = ""; }; 0B0E0B55E2EE75AF67029924 /* SwipeToReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyView.swift; sourceTree = ""; }; 0B32BBA8887BD7A5C4ECF16F /* RoomModerationRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomModerationRole.swift; sourceTree = ""; }; + 0B5DF0E888F66652F8C4CEC5 /* LiveLocationManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerMock.swift; sourceTree = ""; }; 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = ""; }; 0BA7D6C94A50428463D09AF0 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ka; path = ka.lproj/InfoPlist.strings; sourceTree = ""; }; 0BB05221D7D941CC82DC8480 /* LogViewerScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenViewModel.swift; sourceTree = ""; }; @@ -3697,6 +3699,7 @@ 9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */, 7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */, BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */, + 0B5DF0E888F66652F8C4CEC5 /* LiveLocationManagerMock.swift */, 6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */, 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */, 840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */, @@ -8308,6 +8311,7 @@ 74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */, 866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */, 03364DE2568860586A713472 /* LiveLocationManager.swift in Sources */, + 92919B6522B26B2681232EAC /* LiveLocationManagerMock.swift in Sources */, CD077E14FAADC444C5A80068 /* LiveLocationManagerProtocol.swift in Sources */, C8D0AC22E03F652118A2BB73 /* LiveLocationRoomTimelineItem.swift in Sources */, F7977C53B2B1D73030C69761 /* LiveLocationRoomTimelineView.swift in Sources */, diff --git a/ElementX/Sources/Mocks/LiveLocationManagerMock.swift b/ElementX/Sources/Mocks/LiveLocationManagerMock.swift new file mode 100644 index 000000000..dc3100e7d --- /dev/null +++ b/ElementX/Sources/Mocks/LiveLocationManagerMock.swift @@ -0,0 +1,25 @@ +// +// 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 Combine +import CoreLocation + +extension LiveLocationManagerMock { + struct Configuration { + var authorizationStatus: CLAuthorizationStatus = .notDetermined + var requestAlwaysAuthorizationIfPossibleReturnValue = true + } + + convenience init(_ configuration: Configuration) { + self.init() + + let authorizationStatusSubject = CurrentValueSubject(configuration.authorizationStatus) + underlyingAuthorizationStatus = .init(authorizationStatusSubject) + + requestAlwaysAuthorizationIfPossibleReturnValue = configuration.requestAlwaysAuthorizationIfPossibleReturnValue + } +} diff --git a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift index a26ef3ebd..a2c969321 100644 --- a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift +++ b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift @@ -7,34 +7,26 @@ // import Combine +import CoreLocation @testable import ElementX import Testing @MainActor struct LocationSharingScreenViewModelTests { - private let timelineProxy = TimelineProxyMock(.init()) - private var viewModel: LocationSharingScreenViewModelProtocol + var timelineProxy: TimelineProxyMock! + var viewModel: LocationSharingScreenViewModel! - private var context: LocationSharingScreenViewModel.Context { + var context: LocationSharingScreenViewModel.Context { viewModel.context } init() { - let viewModel = LocationSharingScreenViewModel(interactionMode: .picker, - mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, - liveLocationSharingEnabled: true, - roomProxy: JoinedRoomProxyMock(.init()), - timelineController: MockTimelineController(timelineProxy: timelineProxy), - liveLocationManager: LiveLocationManagerMock(), - analytics: ServiceLocator.shared.analytics, - userIndicatorController: UserIndicatorControllerMock(), - mediaProvider: MediaProviderMock(configuration: .init())) - viewModel.state.bindings.isLocationAuthorized = true - self.viewModel = viewModel + AppSettings.resetAllSettings() } @Test - func userDidPan() { + mutating func userDidPan() { + setupViewModel() #expect(context.viewState.isSharingUserLocation) #expect(context.showsUserLocationMode == .showAndFollow) context.send(viewAction: .userDidPan) @@ -43,7 +35,8 @@ struct LocationSharingScreenViewModelTests { } @Test - func centerOnUser() { + mutating func centerOnUser() { + setupViewModel() #expect(context.viewState.isSharingUserLocation) context.showsUserLocationMode = .show #expect(!context.viewState.isSharingUserLocation) @@ -53,7 +46,8 @@ struct LocationSharingScreenViewModelTests { } @Test - func centerOnUserWithoutAuth() { + mutating func centerOnUserWithoutAuth() { + setupViewModel() context.showsUserLocationMode = .hide context.isLocationAuthorized = nil context.send(viewAction: .centerToUser) @@ -61,7 +55,8 @@ struct LocationSharingScreenViewModelTests { } @Test - func centerOnUserWithDeniedAuth() { + mutating func centerOnUserWithDeniedAuth() { + setupViewModel() context.isLocationAuthorized = false context.showsUserLocationMode = .hide context.send(viewAction: .centerToUser) @@ -70,7 +65,8 @@ struct LocationSharingScreenViewModelTests { } @Test - func errorMapping() { + mutating func errorMapping() { + setupViewModel() let mapError = AlertInfo(locationSharingViewError: .mapError(.failedLoadingMap)) #expect(mapError.title == L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName)) let locationError = AlertInfo(locationSharingViewError: .mapError(.failedLocatingUser)) @@ -80,7 +76,8 @@ struct LocationSharingScreenViewModelTests { } @Test - func sendUserLocation() async throws { + mutating func sendUserLocation() async throws { + setupViewModel() context.mapCenterLocation = .init(latitude: 0, longitude: 0) context.geolocationUncertainty = 10 @@ -101,7 +98,8 @@ struct LocationSharingScreenViewModelTests { } @Test - func sendPickedLocation() async throws { + mutating func sendPickedLocation() async throws { + setupViewModel() context.mapCenterLocation = .init(latitude: 0, longitude: 0) context.isLocationAuthorized = nil context.geolocationUncertainty = 10 @@ -121,4 +119,101 @@ struct LocationSharingScreenViewModelTests { try await deferred.fulfill() } } + + // MARK: - Live Location Authorization Tests + + @Test + mutating func startLiveLocationWithDeniedAuth() { + setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .denied)) + context.send(viewAction: .startLiveLocation) + #expect(context.alertInfo?.id == .missingAlwaysAuthorization) + } + + @Test + mutating func startLiveLocationWithRestrictedAuth() { + setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .restricted)) + context.send(viewAction: .startLiveLocation) + #expect(context.alertInfo?.id == .missingAlwaysAuthorization) + } + + @Test + mutating func startLiveLocationWithAlwaysAuth() { + setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways)) + context.send(viewAction: .startLiveLocation) + #expect(context.alertInfo == nil) + } + + @Test + mutating func startLiveLocationWithWhenInUseAuthAlreadyRequested() { + setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse, + requestAlwaysAuthorizationIfPossibleReturnValue: false)) + context.send(viewAction: .startLiveLocation) + #expect(context.alertInfo?.id == .missingAlwaysAuthorization) + } + + @Test + mutating func startLiveLocationWithWhenInUseAuthNotYetRequested() { + setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse, + requestAlwaysAuthorizationIfPossibleReturnValue: true)) + context.send(viewAction: .startLiveLocation) + // The request can be made, so no alert should be shown — it waits for the user to respond to the system prompt + #expect(context.alertInfo == nil) + } + + @Test + mutating func startLiveLocationWithNotDeterminedAuthTransitionsToWhenInUse() async { + let authorizationStatusSubject = CurrentValueSubject(.notDetermined) + let liveLocationManagerMock = LiveLocationManagerMock() + liveLocationManagerMock.underlyingAuthorizationStatus = .init(authorizationStatusSubject) + liveLocationManagerMock.requestAlwaysAuthorizationIfPossibleReturnValue = true + setupViewModel(liveLocationManagerMock: liveLocationManagerMock) + + context.send(viewAction: .startLiveLocation) + + // No alert yet — waiting for MapLibre to resolve the status to whenInUse + #expect(context.alertInfo == nil) + + // Simulate MapLibre resolving the authorization to whenInUse, and confirm that the ViewModel + // recurses and calls requestAlwaysAuthorizationIfPossible as a result + await waitForConfirmation { confirmation in + liveLocationManagerMock.requestAlwaysAuthorizationIfPossibleClosure = { + confirmation() + return true + } + authorizationStatusSubject.send(.authorizedWhenInUse) + } + + // The request was made, so no alert — waiting for the always authorization prompt response + #expect(context.alertInfo == nil) + } + + // MARK: - Private + + private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) { + timelineProxy = TimelineProxyMock(.init()) + viewModel = LocationSharingScreenViewModel(interactionMode: .picker, + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + liveLocationSharingEnabled: true, + roomProxy: JoinedRoomProxyMock(.init()), + timelineController: MockTimelineController(timelineProxy: timelineProxy), + liveLocationManager: LiveLocationManagerMock(liveLocationManagerConfiguration), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock(), + mediaProvider: MediaProviderMock(configuration: .init())) + viewModel.state.bindings.isLocationAuthorized = true + } + + private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock) { + timelineProxy = TimelineProxyMock(.init()) + viewModel = LocationSharingScreenViewModel(interactionMode: .picker, + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + liveLocationSharingEnabled: true, + roomProxy: JoinedRoomProxyMock(.init()), + timelineController: MockTimelineController(timelineProxy: timelineProxy), + liveLocationManager: liveLocationManagerMock, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock(), + mediaProvider: MediaProviderMock(configuration: .init())) + viewModel.state.bindings.isLocationAuthorized = true + } }