implemented the usage of the live location manager permission handling in the location sharing screen.

This commit is contained in:
Mauro Romito
2026-03-31 02:10:21 +02:00
committed by Mauro
parent 7e00e9d6fe
commit 9e17fd0315
11 changed files with 86 additions and 3 deletions

View File

@@ -409,6 +409,7 @@
"dialog_file_too_large_to_upload_title" = "The file size is too large to upload"; "dialog_file_too_large_to_upload_title" = "The file size is too large to upload";
"dialog_permission_camera" = "In order to let the application use the camera, please grant the permission in the system settings."; "dialog_permission_camera" = "In order to let the application use the camera, please grant the permission in the system settings.";
"dialog_permission_generic" = "Please grant the permission in the system settings."; "dialog_permission_generic" = "Please grant the permission in the system settings.";
"dialog_permission_live_location_description_ios" = "To share your live location, %1$@ needs location access when the app is in the background. Go to Settings > Location and select Always";
"dialog_permission_location_description_ios" = "To share your current location, %1$@ needs location access. Go to Settings > Location."; "dialog_permission_location_description_ios" = "To share your current location, %1$@ needs location access. Go to Settings > Location.";
"dialog_permission_location_title_ios" = "%1$@ does not have access to your location."; "dialog_permission_location_title_ios" = "%1$@ does not have access to your location.";
"dialog_permission_microphone" = "In order to let the application use the microphone, please grant the permission in the system settings."; "dialog_permission_microphone" = "In order to let the application use the microphone, please grant the permission in the system settings.";

View File

@@ -1,6 +1,6 @@
"NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera."; "NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera.";
"NSFaceIDUsageDescription" = "Face ID is used to access your app."; "NSFaceIDUsageDescription" = "Face ID is used to access your app.";
"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" = "Grant location access so that Element X can share your location."; "NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location.";
"NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone."; "NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone.";
"NSPhotoLibraryUsageDescription" = "This lets you save images and videos to your photo library."; "NSPhotoLibraryUsageDescription" = "This lets you save images and videos to your photo library.";

View File

@@ -409,6 +409,7 @@
"dialog_file_too_large_to_upload_title" = "The file size is too large to upload"; "dialog_file_too_large_to_upload_title" = "The file size is too large to upload";
"dialog_permission_camera" = "In order to let the application use the camera, please grant the permission in the system settings."; "dialog_permission_camera" = "In order to let the application use the camera, please grant the permission in the system settings.";
"dialog_permission_generic" = "Please grant the permission in the system settings."; "dialog_permission_generic" = "Please grant the permission in the system settings.";
"dialog_permission_live_location_description_ios" = "To share your live location, %1$@ needs location access when the app is in the background. Go to Settings > Location and select Always";
"dialog_permission_location_description_ios" = "To share your current location, %1$@ needs location access. Go to Settings > Location."; "dialog_permission_location_description_ios" = "To share your current location, %1$@ needs location access. Go to Settings > Location.";
"dialog_permission_location_title_ios" = "%1$@ does not have access to your location."; "dialog_permission_location_title_ios" = "%1$@ does not have access to your location.";
"dialog_permission_microphone" = "In order to let the application use the microphone, please grant the permission in the system settings."; "dialog_permission_microphone" = "In order to let the application use the microphone, please grant the permission in the system settings.";

View File

@@ -108,6 +108,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled, liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
liveLocationManager: flowParameters.userSession.liveLocationManager,
appMediator: flowParameters.appMediator, appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics, analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,

View File

@@ -1139,6 +1139,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled, liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
liveLocationManager: flowParameters.userSession.liveLocationManager,
appMediator: flowParameters.appMediator, appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics, analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,

View File

@@ -936,6 +936,10 @@ internal enum L10n {
internal static var dialogPermissionCamera: String { return L10n.tr("Localizable", "dialog_permission_camera") } internal static var dialogPermissionCamera: String { return L10n.tr("Localizable", "dialog_permission_camera") }
/// Please grant the permission in the system settings. /// Please grant the permission in the system settings.
internal static var dialogPermissionGeneric: String { return L10n.tr("Localizable", "dialog_permission_generic") } internal static var dialogPermissionGeneric: String { return L10n.tr("Localizable", "dialog_permission_generic") }
/// To share your live location, %1$@ needs location access when the app is in the background. Go to Settings > Location and select Always
internal static func dialogPermissionLiveLocationDescriptionIos(_ p1: Any) -> String {
return L10n.tr("Localizable", "dialog_permission_live_location_description_ios", String(describing: p1))
}
/// To share your current location, %1$@ needs location access. Go to Settings > Location. /// To share your current location, %1$@ needs location access. Go to Settings > Location.
internal static func dialogPermissionLocationDescriptionIos(_ p1: Any) -> String { internal static func dialogPermissionLocationDescriptionIos(_ p1: Any) -> String {
return L10n.tr("Localizable", "dialog_permission_location_description_ios", String(describing: p1)) return L10n.tr("Localizable", "dialog_permission_location_description_ios", String(describing: p1))

View File

@@ -15,6 +15,7 @@ struct LocationSharingScreenCoordinatorParameters {
let liveLocationSharingEnabled: Bool let liveLocationSharingEnabled: Bool
let roomProxy: JoinedRoomProxyProtocol let roomProxy: JoinedRoomProxyProtocol
let timelineController: TimelineControllerProtocol let timelineController: TimelineControllerProtocol
let liveLocationManager: LiveLocationManagerProtocol
let appMediator: AppMediatorProtocol let appMediator: AppMediatorProtocol
let analytics: AnalyticsService let analytics: AnalyticsService
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
@@ -44,6 +45,7 @@ final class LocationSharingScreenCoordinator: CoordinatorProtocol {
liveLocationSharingEnabled: parameters.liveLocationSharingEnabled, liveLocationSharingEnabled: parameters.liveLocationSharingEnabled,
roomProxy: parameters.roomProxy, roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
liveLocationManager: parameters.liveLocationManager,
analytics: parameters.analytics, analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController, userIndicatorController: parameters.userIndicatorController,
mediaProvider: parameters.mediaProvider) mediaProvider: parameters.mediaProvider)

View File

@@ -12,6 +12,7 @@ import MatrixRustSDK
enum LocationSharingViewError: Error, Hashable { enum LocationSharingViewError: Error, Hashable {
case missingAuthorization case missingAuthorization
case missingAlwaysAuthorization
case mapError(MapLibreError) case mapError(MapLibreError)
} }
@@ -142,6 +143,7 @@ struct LocationSharingScreenBindings {
enum LocationSharingScreenViewAction { enum LocationSharingScreenViewAction {
case close case close
case selectLocation case selectLocation
case startLiveLocation
case centerToUser case centerToUser
case userDidPan case userDidPan
} }
@@ -157,6 +159,12 @@ extension AlertInfo where T == LocationSharingViewError {
message: L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName), message: L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton, primaryButton: primaryButton,
secondaryButton: secondaryButton) secondaryButton: secondaryButton)
case .missingAlwaysAuthorization:
self.init(id: error,
title: L10n.dialogAllowAccess,
message: L10n.dialogPermissionLiveLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
case .mapError(.failedLoadingMap): case .mapError(.failedLoadingMap):
self.init(id: error, self.init(id: error,
title: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName), title: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),

View File

@@ -8,32 +8,41 @@
import Combine import Combine
import Foundation import Foundation
import UIKit
typealias LocationSharingScreenViewModelType = StateStoreViewModelV2<LocationSharingScreenViewState, LocationSharingScreenViewAction> typealias LocationSharingScreenViewModelType = StateStoreViewModelV2<LocationSharingScreenViewState, LocationSharingScreenViewAction>
class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, LocationSharingScreenViewModelProtocol { class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, LocationSharingScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol private let roomProxy: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol private let timelineController: TimelineControllerProtocol
private let liveLocationManager: LiveLocationManagerProtocol
private let analytics: AnalyticsService private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let notificationCenter: NotificationCenter
private let actionsSubject: PassthroughSubject<LocationSharingScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<LocationSharingScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<LocationSharingScreenViewModelAction, Never> { var actions: AnyPublisher<LocationSharingScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
private var authorizationStatusSubscription: AnyCancellable?
init(interactionMode: LocationSharingInteractionMode, init(interactionMode: LocationSharingInteractionMode,
mapURLBuilder: MapTilerURLBuilderProtocol, mapURLBuilder: MapTilerURLBuilderProtocol,
liveLocationSharingEnabled: Bool, liveLocationSharingEnabled: Bool,
roomProxy: JoinedRoomProxyProtocol, roomProxy: JoinedRoomProxyProtocol,
timelineController: TimelineControllerProtocol, timelineController: TimelineControllerProtocol,
liveLocationManager: LiveLocationManagerProtocol,
analytics: AnalyticsService, analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol,
mediaProvider: MediaProviderProtocol) { mediaProvider: MediaProviderProtocol,
notificationCenter: NotificationCenter = .default) {
self.roomProxy = roomProxy self.roomProxy = roomProxy
self.timelineController = timelineController self.timelineController = timelineController
self.liveLocationManager = liveLocationManager
self.analytics = analytics self.analytics = analytics
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.notificationCenter = notificationCenter
super.init(initialViewState: .init(interactionMode: interactionMode, super.init(initialViewState: .init(interactionMode: interactionMode,
mapURLBuilder: mapURLBuilder, mapURLBuilder: mapURLBuilder,
@@ -49,6 +58,8 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
switch viewAction { switch viewAction {
case .close: case .close:
actionsSubject.send(.close) actionsSubject.send(.close)
case .startLiveLocation:
startLiveLocationSharing()
case .selectLocation: case .selectLocation:
guard let coordinate = state.bindings.mapCenterLocation else { return } guard let coordinate = state.bindings.mapCenterLocation else { return }
let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil
@@ -75,6 +86,13 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
self?.updateShownUserProfile(members: members) self?.updateShownUserProfile(members: members)
} }
.store(in: &cancellables) .store(in: &cancellables)
notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
// Let's remove the subscription if the user backgrounds the app (maybe to change their location settings)
self?.authorizationStatusSubscription = nil
}
.store(in: &cancellables)
} }
private func updateShownUserProfile(members: [RoomMemberProxyProtocol]) { private func updateShownUserProfile(members: [RoomMemberProxyProtocol]) {
@@ -94,6 +112,49 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
} }
private func startLiveLocationSharing() {
authorizationStatusSubscription = nil
let authStatus = liveLocationManager.authorizationStatus.value
switch authStatus {
case .authorizedAlways:
// TODO: Start sending live location updates to the room
break
case .notDetermined:
// This is to solve a race condition with map libre which always tries first
// to request the when in use permission, we wait for it and then try again
authorizationStatusSubscription = liveLocationManager.authorizationStatus
.filter { $0 != authStatus } // skip current status
.first() // this publisher only fires when there is an actual change, and if the user is done with permissions
.sink { [weak self] newValue in
guard newValue == .authorizedWhenInUse else { return }
self?.startLiveLocationSharing()
}
case .authorizedWhenInUse:
guard liveLocationManager.requestAlwaysAuthorizationIfPossible() else {
// Already requested before iOS won't show the prompt again.
showMissingAlwaysAuthorizedAlert()
break
}
authorizationStatusSubscription = liveLocationManager.authorizationStatus
.filter { $0 != authStatus } // skip current status
.first() // this publisher only fires when there is an actual change, and if the user is done with permissions
.sink { newValue in
guard newValue == .authorizedAlways else { return }
// TODO: Start sending live location updates to the room
}
default:
showMissingAlwaysAuthorizedAlert()
}
}
private func showMissingAlwaysAuthorizedAlert() {
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
state.bindings.alertInfo = .init(locationSharingViewError: .missingAlwaysAuthorization,
primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.commonSettings, action: action))
}
private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async { private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async {
guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage, guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage,
geoURI: geoURI, geoURI: geoURI,
@@ -157,6 +218,7 @@ extension LocationSharingScreenViewModel {
liveLocationSharingEnabled: liveLocationSharingEnabled, liveLocationSharingEnabled: liveLocationSharingEnabled,
roomProxy: JoinedRoomProxyMock(.init()), roomProxy: JoinedRoomProxyMock(.init()),
timelineController: MockTimelineController(), timelineController: MockTimelineController(),
liveLocationManager: LiveLocationManagerMock(),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))

View File

@@ -40,7 +40,9 @@ struct LocationPickerSheet: View {
} }
} }
if context.viewState.showLiveLocationSharingButton { if context.viewState.showLiveLocationSharingButton {
Button { } label: { Button {
context.send(viewAction: .startLiveLocation)
} label: {
LocationPickerLabel(text: L10n.actionShareLiveLocation, LocationPickerLabel(text: L10n.actionShareLiveLocation,
icon: \.locationPinSolid, icon: \.locationPinSolid,
iconColor: .compound.iconAccentPrimary) iconColor: .compound.iconAccentPrimary)

View File

@@ -25,6 +25,7 @@ struct LocationSharingScreenViewModelTests {
liveLocationSharingEnabled: true, liveLocationSharingEnabled: true,
roomProxy: JoinedRoomProxyMock(.init()), roomProxy: JoinedRoomProxyMock(.init()),
timelineController: MockTimelineController(timelineProxy: timelineProxy), timelineController: MockTimelineController(timelineProxy: timelineProxy),
liveLocationManager: LiveLocationManagerMock(),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))