implemented the LiveLocationManager as part of the user session and setup of permissions in the plist.

This commit is contained in:
Mauro Romito
2026-03-31 02:09:25 +02:00
committed by Mauro
parent 3ba636aa37
commit b31d0cad75
9 changed files with 200 additions and 6 deletions
+16
View File
@@ -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 = "<group>"; };
330AF4D121C3396F7A14B21D /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/SAS.strings; sourceTree = "<group>"; };
3339B1DDB1341E833D2555BC /* AVMetadataMachineReadableCodeObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVMetadataMachineReadableCodeObject.swift; sourceTree = "<group>"; };
33752AE856E93CE62412B7A1 /* LiveLocationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerProtocol.swift; sourceTree = "<group>"; };
33AE897D86784CCA5E4E9227 /* ElementCallService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallService.swift; sourceTree = "<group>"; };
33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = "<group>"; };
342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModelProtocol.swift; sourceTree = "<group>"; };
@@ -2719,6 +2722,7 @@
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListScreenViewModel.swift; sourceTree = "<group>"; };
D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = "<group>"; };
D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManager.swift; sourceTree = "<group>"; };
D1896F6288D80E1F3EFB3DF8 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ka; path = ka.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
D196116D2DD3F2757D45FCB7 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/SAS.strings; sourceTree = "<group>"; };
D1BC84BA0AF11C2128D58ABD /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
90FD376E9373246E4925CE95 /* Location */ = {
isa = PBXGroup;
children = (
D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */,
33752AE856E93CE62412B7A1 /* LiveLocationManagerProtocol.swift */,
);
path = Location;
sourceTree = "<group>";
};
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 */,
@@ -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" = "Дазваляе захоўваць фатаграфіі і відэа ў вашу бібліятэку.";
@@ -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
@@ -11100,6 +11100,79 @@ class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendabl
}
}
}
class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable {
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> {
get { return underlyingAuthorizationStatus }
set(value) { underlyingAuthorizationStatus = value }
}
var underlyingAuthorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never>!
//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<SessionSecurityState, Never> {
get { return underlyingSessionSecurityStatePublisher }
set(value) { underlyingSessionSecurityStatePublisher = value }
@@ -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<CLAuthorizationStatus, Never>
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> {
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
}
@@ -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<CLAuthorizationStatus, Never> { 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
}
@@ -17,6 +17,7 @@ class UserSession: UserSessionProtocol {
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
let liveLocationManager: LiveLocationManagerProtocol
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
@@ -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)
@@ -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<SessionSecurityState, Never> { get }
@@ -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<ClientProxyProtocol, UserSessionStoreError> {