From 7e00e9d6fed92b07b9aba1e4418bcc0fe20d78b0 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Tue, 31 Mar 2026 02:09:25 +0200 Subject: [PATCH] implemented the LiveLocationManager as part of the user session and setup of permissions in the plist. --- ElementX.xcodeproj/project.pbxproj | 16 ++++ .../Localizations/be.lproj/InfoPlist.strings | 2 +- .../Application/Settings/AppSettings.swift | 4 + .../Mocks/Generated/GeneratedMocks.swift | 78 +++++++++++++++++++ .../Location/LiveLocationManager.swift | 64 +++++++++++++++ .../LiveLocationManagerProtocol.swift | 23 ++++++ .../Services/Session/UserSession.swift | 4 +- .../Session/UserSessionProtocol.swift | 1 + .../UserSession/UserSessionStore.swift | 14 +++- 9 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 ElementX/Sources/Services/Location/LiveLocationManager.swift create mode 100644 ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 992a2142a..3a008868d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 02A92F8F4538CECDFB4F2607 /* RoomDirectorySearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1562EAF6231151A675BED7A9 /* RoomDirectorySearchScreenCoordinator.swift */; }; 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; 0307469D99B5FE6C7043AE39 /* KnockRequestsListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E8A8EC299E12490588B07C /* KnockRequestsListScreenCoordinator.swift */; }; + 03364DE2568860586A713472 /* LiveLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */; }; 03631FC946FF0BBAD37E22BF /* preview_avatar_user.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 87FC42213E86E8182CFD3A49 /* preview_avatar_user.jpg */; }; 037006FB6DF1374F94E4058D /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */; }; 038AB2E86960FD240231D4C2 /* GeneratedPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A2E4BD7C0CAD25EF924A4C /* GeneratedPreviewTests.swift */; }; @@ -1188,6 +1189,7 @@ CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */; }; CCBEC2100CAF2EEBE9DB4156 /* TemplateScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */; }; CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5E97E9615A158C76B2AB77 /* DateTests.swift */; }; + CD077E14FAADC444C5A80068 /* LiveLocationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33752AE856E93CE62412B7A1 /* LiveLocationManagerProtocol.swift */; }; CD6A72B65D3B6076F4045C30 /* PHGPostHogConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */; }; CDAE3A37D4DF136F9D07DB61 /* RoomChangeRolesScreenSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF710CB1C31F8938EAA3A7D /* RoomChangeRolesScreenSection.swift */; }; CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */; }; @@ -1865,6 +1867,7 @@ 33035418BB35754232985871 /* TimelineReadReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReadReceiptsView.swift; sourceTree = ""; }; 330AF4D121C3396F7A14B21D /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/SAS.strings; sourceTree = ""; }; 3339B1DDB1341E833D2555BC /* AVMetadataMachineReadableCodeObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVMetadataMachineReadableCodeObject.swift; sourceTree = ""; }; + 33752AE856E93CE62412B7A1 /* LiveLocationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerProtocol.swift; sourceTree = ""; }; 33AE897D86784CCA5E4E9227 /* ElementCallService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallService.swift; sourceTree = ""; }; 33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2719,6 +2722,7 @@ D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListScreenViewModel.swift; sourceTree = ""; }; D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = ""; }; + D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManager.swift; sourceTree = ""; }; D1896F6288D80E1F3EFB3DF8 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ka; path = ka.lproj/Localizable.stringsdict; sourceTree = ""; }; D196116D2DD3F2757D45FCB7 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/SAS.strings; sourceTree = ""; }; D1BC84BA0AF11C2128D58ABD /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; @@ -3192,6 +3196,7 @@ 39557ADF21345E18F3865B9E /* Emojis */, CA555F7C7CA382ACACF0D82B /* Keychain */, DCF22D1E268F1A36EC3431E4 /* LinkMetadata */, + 90FD376E9373246E4925CE95 /* Location */, 79E560F5113ED25D172E550C /* Media */, 6709362D60732DED2069AE0F /* MediaPlayer */, 6DE13A7AE6587B079F4049D7 /* Notification */, @@ -5388,6 +5393,15 @@ path = LoginScreen; sourceTree = ""; }; + 90FD376E9373246E4925CE95 /* Location */ = { + isa = PBXGroup; + children = ( + D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */, + 33752AE856E93CE62412B7A1 /* LiveLocationManagerProtocol.swift */, + ); + path = Location; + sourceTree = ""; + }; 92E99C57D7F92ED16F73282C /* ElementCall */ = { isa = PBXGroup; children = ( @@ -8293,6 +8307,8 @@ A37BFB32EAB8AEF6DD5BA0DC /* LinkNewDeviceService.swift in Sources */, 74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */, 866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */, + 03364DE2568860586A713472 /* LiveLocationManager.swift in Sources */, + CD077E14FAADC444C5A80068 /* LiveLocationManagerProtocol.swift in Sources */, C8D0AC22E03F652118A2BB73 /* LiveLocationRoomTimelineItem.swift in Sources */, F7977C53B2B1D73030C69761 /* LiveLocationRoomTimelineView.swift in Sources */, 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */, diff --git a/ElementX/Resources/Localizations/be.lproj/InfoPlist.strings b/ElementX/Resources/Localizations/be.lproj/InfoPlist.strings index 74bb558c3..a85d224cf 100644 --- a/ElementX/Resources/Localizations/be.lproj/InfoPlist.strings +++ b/ElementX/Resources/Localizations/be.lproj/InfoPlist.strings @@ -1,6 +1,6 @@ "NSCameraUsageDescription" = "Каб зрабіць фота ці відэа і адправіць іх у выглядзе паведамлення, Element X патрабуе доступу да камеры."; "NSFaceIDUsageDescription" = "Face ID выкарыстоўваецца для доступу да праграмы."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "To share your live location, Element X needs location access when the app is in the background."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "To share your live location, Element X needs location access when the app is in the background."; "NSLocationWhenInUseUsageDescription" = "Дайце доступ да месцазнаходжання, каб Element X мог падзяліцца вашым месцазнаходжаннем."; "NSMicrophoneUsageDescription" = "Каб запісваць і адпраўляць паведамленні з гукам, Element X патрабуе доступу да мікрафона."; "NSPhotoLibraryUsageDescription" = "Дазваляе захоўваць фатаграфіі і відэа ў вашу бібліятэку."; diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index b6e849aad..043b7c602 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -48,6 +48,7 @@ final class AppSettings { case analyticsConsentState case hasRunNotificationPermissionsOnboarding case hasRunIdentityConfirmationOnboarding + case hasRequestedAlwaysLocationAuthorization case frequentlyUsedSystemEmojis @@ -343,6 +344,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding, defaultValue: false, storageType: .userDefaults(store)) var hasRunIdentityConfirmationOnboarding + @UserPreference(key: UserDefaultsKeys.hasRequestedAlwaysLocationAuthorization, defaultValue: false, storageType: .userDefaults(store)) + var hasRequestedAlwaysLocationAuthorization + @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 19c08ed8d..f467f24e8 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -11100,6 +11100,79 @@ class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendabl } } } +class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable { + var authorizationStatus: CurrentValuePublisher { + get { return underlyingAuthorizationStatus } + set(value) { underlyingAuthorizationStatus = value } + } + var underlyingAuthorizationStatus: CurrentValuePublisher! + + //MARK: - requestAlwaysAuthorizationIfPossible + + var requestAlwaysAuthorizationIfPossibleUnderlyingCallsCount = 0 + var requestAlwaysAuthorizationIfPossibleCallsCount: Int { + get { + if Thread.isMainThread { + return requestAlwaysAuthorizationIfPossibleUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = requestAlwaysAuthorizationIfPossibleUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + requestAlwaysAuthorizationIfPossibleUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + requestAlwaysAuthorizationIfPossibleUnderlyingCallsCount = newValue + } + } + } + } + var requestAlwaysAuthorizationIfPossibleCalled: Bool { + return requestAlwaysAuthorizationIfPossibleCallsCount > 0 + } + + var requestAlwaysAuthorizationIfPossibleUnderlyingReturnValue: Bool! + var requestAlwaysAuthorizationIfPossibleReturnValue: Bool! { + get { + if Thread.isMainThread { + return requestAlwaysAuthorizationIfPossibleUnderlyingReturnValue + } else { + var returnValue: Bool? = nil + DispatchQueue.main.sync { + returnValue = requestAlwaysAuthorizationIfPossibleUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + requestAlwaysAuthorizationIfPossibleUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + requestAlwaysAuthorizationIfPossibleUnderlyingReturnValue = newValue + } + } + } + } + var requestAlwaysAuthorizationIfPossibleClosure: (() -> Bool)? + + @discardableResult + func requestAlwaysAuthorizationIfPossible() -> Bool { + requestAlwaysAuthorizationIfPossibleCallsCount += 1 + if let requestAlwaysAuthorizationIfPossibleClosure = requestAlwaysAuthorizationIfPossibleClosure { + return requestAlwaysAuthorizationIfPossibleClosure() + } else { + return requestAlwaysAuthorizationIfPossibleReturnValue + } + } +} class MediaLoaderMock: MediaLoaderProtocol, @unchecked Sendable { //MARK: - loadMediaContentForSource @@ -20236,6 +20309,11 @@ class UserSessionMock: UserSessionProtocol, @unchecked Sendable { set(value) { underlyingVoiceMessageMediaManager = value } } var underlyingVoiceMessageMediaManager: VoiceMessageMediaManagerProtocol! + var liveLocationManager: LiveLocationManagerProtocol { + get { return underlyingLiveLocationManager } + set(value) { underlyingLiveLocationManager = value } + } + var underlyingLiveLocationManager: LiveLocationManagerProtocol! var sessionSecurityStatePublisher: CurrentValuePublisher { get { return underlyingSessionSecurityStatePublisher } set(value) { underlyingSessionSecurityStatePublisher = value } diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift new file mode 100644 index 000000000..64f75dac9 --- /dev/null +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -0,0 +1,64 @@ +// +// 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 + +class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationManagerDelegate { + private let clientProxy: ClientProxyProtocol + private let locationManager: CLLocationManager + private let appSettings: AppSettings + + private let authorizationStatusSubject: CurrentValueSubject + + var authorizationStatus: CurrentValuePublisher { + authorizationStatusSubject.asCurrentValuePublisher() + } + + @MainActor + init(clientProxy: ClientProxyProtocol, + appSettings: AppSettings) { + self.clientProxy = clientProxy + self.appSettings = appSettings + // Very important, the CLLocationManager needs to be initialised on the main thread + // or the delegate functions won't be handled! + // https://developer.apple.com/documentation/corelocation/cllocationmanagerdelegate + locationManager = CLLocationManager() + authorizationStatusSubject = CurrentValueSubject(locationManager.authorizationStatus) + + super.init() + + locationManager.delegate = self + } + + // MARK: - LiveLocationManagerProtocol + + @discardableResult + func requestAlwaysAuthorizationIfPossible() -> Bool { + guard !appSettings.hasRequestedAlwaysLocationAuthorization else { return false } + appSettings.hasRequestedAlwaysLocationAuthorization = true + locationManager.requestAlwaysAuthorization() + return true + } + + // MARK: - CLLocationManagerDelegate + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + // If the system resets authorization to notDetermined (e.g. after app reinstall or + // settings reset), clear the flag so we can request again. + if manager.authorizationStatus == .notDetermined { + appSettings.hasRequestedAlwaysLocationAuthorization = false + } + authorizationStatusSubject.send(manager.authorizationStatus) + } + + // MARK: - Private + + // TODO: Add CLLocationManager location update handling to forward updates to rooms + // TODO: Track which rooms are currently sharing live location + // TODO: Send location updates to all active rooms via clientProxy +} diff --git a/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift b/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift new file mode 100644 index 000000000..4f5094b1d --- /dev/null +++ b/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift @@ -0,0 +1,23 @@ +// +// 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 +import Foundation + +// sourcery: AutoMockable +protocol LiveLocationManagerProtocol: AnyObject { + /// Publishes the current "Always" location authorization status. + var authorizationStatus: CurrentValuePublisher { get } + + /// Requests "Always" location authorization from the user if the system allows it. + /// + /// - Returns: `true` if the request was forwarded to the system and a prompt will be shown; + /// `false` if the request was already made before and iOS would silently ignore it. + @discardableResult + func requestAlwaysAuthorizationIfPossible() -> Bool +} diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index eeff92df9..dbfdfcd81 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -17,6 +17,7 @@ class UserSession: UserSessionProtocol { let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol + let liveLocationManager: LiveLocationManagerProtocol let callbacks = PassthroughSubject() @@ -25,10 +26,11 @@ class UserSession: UserSessionProtocol { sessionSecurityStateSubject.asCurrentValuePublisher() } - init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) { + init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, liveLocationManager: LiveLocationManagerProtocol) { self.clientProxy = clientProxy self.mediaProvider = mediaProvider self.voiceMessageMediaManager = voiceMessageMediaManager + self.liveLocationManager = liveLocationManager authErrorCancellable = clientProxy.actionsPublisher .receive(on: DispatchQueue.main) diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index f3b7e5ca7..fb55e189f 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -23,6 +23,7 @@ protocol UserSessionProtocol { var clientProxy: ClientProxyProtocol { get } var mediaProvider: MediaProviderProtocol { get } var voiceMessageMediaManager: VoiceMessageMediaManagerProtocol { get } + var liveLocationManager: LiveLocationManagerProtocol { get } var sessionSecurityStatePublisher: CurrentValuePublisher { get } diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 41326d798..2d432d79e 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -58,7 +58,7 @@ class UserSessionStore: UserSessionStoreProtocol { switch await restorePreviousLogin(credentials) { case .success(let clientProxy): - return .success(buildUserSessionWithClient(clientProxy)) + return await .success(buildUserSessionWithClient(clientProxy)) case .failure(let error): MXLog.error("Failed restoring login with error: \(error)") @@ -84,7 +84,7 @@ class UserSessionStore: UserSessionStoreProtocol { MXLog.info("Set up session for user \(userID) at: \(sessionDirectories)") - return .success(buildUserSessionWithClient(clientProxy)) + return await .success(buildUserSessionWithClient(clientProxy)) } catch { MXLog.error("Failed creating user session with error: \(error)") return .failure(.failedSettingUpSession) @@ -103,16 +103,22 @@ class UserSessionStore: UserSessionStoreProtocol { // MARK: - Private - private func buildUserSessionWithClient(_ clientProxy: ClientProxyProtocol) -> UserSessionProtocol { + private func buildUserSessionWithClient(_ clientProxy: ClientProxyProtocol) async -> UserSessionProtocol { let mediaProvider = MediaProvider(mediaLoader: clientProxy.mediaLoader, imageCache: .onlyInMemory, homeserverReachabilityPublisher: clientProxy.homeserverReachabilityPublisher) let voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider) + let liveLocationManager = await MainActor.run { + LiveLocationManager(clientProxy: clientProxy, + appSettings: appSettings) + } + return UserSession(clientProxy: clientProxy, mediaProvider: mediaProvider, - voiceMessageMediaManager: voiceMessageMediaManager) + voiceMessageMediaManager: voiceMessageMediaManager, + liveLocationManager: liveLocationManager) } private func restorePreviousLogin(_ credentials: KeychainCredentials) async -> Result {