implement the start, send and stop functions

This commit is contained in:
Mauro Romito
2026-04-02 17:18:18 +02:00
committed by Mauro
parent 2ced4f5427
commit eabfdcd035
6 changed files with 531 additions and 0 deletions

View File

@@ -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

View File

@@ -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<Void, RoomProxyError>!
var startLiveLocationShareDurationMillisReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return startLiveLocationShareDurationMillisUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = 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<Void, RoomProxyError>)?
func startLiveLocationShare(durationMillis: UInt64) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var sendLiveLocationGeoURIReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return sendLiveLocationGeoURIUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = 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<Void, RoomProxyError>)?
func sendLiveLocation(geoURI: GeoURI) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var stopLiveLocationShareReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return stopLiveLocationShareUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = stopLiveLocationShareUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
stopLiveLocationShareUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
stopLiveLocationShareUnderlyingReturnValue = newValue
}
}
}
}
var stopLiveLocationShareClosure: (() async -> Result<Void, RoomProxyError>)?
func stopLiveLocationShare() async -> Result<Void, RoomProxyError> {
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<Void, LiveLocationManagerError>!
var startLiveLocationRoomIDDurationMillisReturnValue: Result<Void, LiveLocationManagerError>! {
get {
if Thread.isMainThread {
return startLiveLocationRoomIDDurationMillisUnderlyingReturnValue
} else {
var returnValue: Result<Void, LiveLocationManagerError>? = 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<Void, LiveLocationManagerError>)?
func startLiveLocation(roomID: String, durationMillis: UInt64) async -> Result<Void, LiveLocationManagerError> {
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 {

View File

@@ -15,6 +15,15 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
private let authorizationStatusSubject: CurrentValueSubject<CLAuthorizationStatus, Never>
/// 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<Void, Never>?
private var cancellables = Set<AnyCancellable>()
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> {
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<Void, LiveLocationManagerError> {
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)
}
}
}
}

View File

@@ -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<Void, LiveLocationManagerError>
/// 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
}

View File

@@ -751,6 +751,38 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
return .failure(.sdkError(error))
}
}
// MARK: - Live Location
func startLiveLocationShare(durationMillis: UInt64) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError> {
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<Void, RoomProxyError> {
do {
try await room.stopLiveLocationShare()
return .success(())
} catch {
MXLog.error("Failed stopping live location share with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Private

View File

@@ -194,6 +194,12 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
func saveDraft(_ draft: ComposerDraft, threadRootEventID: String?) async -> Result<Void, RoomProxyError>
func loadDraft(threadRootEventID: String?) async -> Result<ComposerDraft?, RoomProxyError>
func clearDraft(threadRootEventID: String?) async -> Result<Void, RoomProxyError>
// MARK: - Live Location
func startLiveLocationShare(durationMillis: UInt64) async -> Result<Void, RoomProxyError>
func sendLiveLocation(geoURI: GeoURI) async -> Result<Void, RoomProxyError>
func stopLiveLocationShare() async -> Result<Void, RoomProxyError>
}
extension JoinedRoomProxyProtocol {