diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index a63b03494..5205cf4f0 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -409,6 +409,7 @@ "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_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_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."; diff --git a/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings b/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings index 14d725c20..af5fe8f29 100644 --- a/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings +++ b/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings @@ -1,6 +1,6 @@ "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."; -"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."; "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."; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 938eb0fb5..6d1d36699 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -409,6 +409,7 @@ "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_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_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."; diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index 041100db1..ca797364a 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -108,6 +108,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled, roomProxy: roomProxy, timelineController: timelineController, + liveLocationManager: flowParameters.userSession.liveLocationManager, appMediator: flowParameters.appMediator, analytics: flowParameters.analytics, userIndicatorController: flowParameters.userIndicatorController, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 2d242b048..7c9ca3ec0 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1139,6 +1139,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled, roomProxy: roomProxy, timelineController: timelineController, + liveLocationManager: flowParameters.userSession.liveLocationManager, appMediator: flowParameters.appMediator, analytics: flowParameters.analytics, userIndicatorController: flowParameters.userIndicatorController, diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 4085b66bc..fdbbfe37c 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -936,6 +936,10 @@ internal enum L10n { internal static var dialogPermissionCamera: String { return L10n.tr("Localizable", "dialog_permission_camera") } /// Please grant the permission in the system settings. 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. internal static func dialogPermissionLocationDescriptionIos(_ p1: Any) -> String { return L10n.tr("Localizable", "dialog_permission_location_description_ios", String(describing: p1)) diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift index 6c4e0828d..5a5e03b6e 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift @@ -15,6 +15,7 @@ struct LocationSharingScreenCoordinatorParameters { let liveLocationSharingEnabled: Bool let roomProxy: JoinedRoomProxyProtocol let timelineController: TimelineControllerProtocol + let liveLocationManager: LiveLocationManagerProtocol let appMediator: AppMediatorProtocol let analytics: AnalyticsService let userIndicatorController: UserIndicatorControllerProtocol @@ -44,6 +45,7 @@ final class LocationSharingScreenCoordinator: CoordinatorProtocol { liveLocationSharingEnabled: parameters.liveLocationSharingEnabled, roomProxy: parameters.roomProxy, timelineController: parameters.timelineController, + liveLocationManager: parameters.liveLocationManager, analytics: parameters.analytics, userIndicatorController: parameters.userIndicatorController, mediaProvider: parameters.mediaProvider) diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift index 5764b035e..dd22887ba 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -12,6 +12,7 @@ import MatrixRustSDK enum LocationSharingViewError: Error, Hashable { case missingAuthorization + case missingAlwaysAuthorization case mapError(MapLibreError) } @@ -142,6 +143,7 @@ struct LocationSharingScreenBindings { enum LocationSharingScreenViewAction { case close case selectLocation + case startLiveLocation case centerToUser case userDidPan } @@ -157,6 +159,12 @@ extension AlertInfo where T == LocationSharingViewError { message: L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName), primaryButton: primaryButton, secondaryButton: secondaryButton) + case .missingAlwaysAuthorization: + self.init(id: error, + title: L10n.dialogAllowAccess, + message: L10n.dialogPermissionLiveLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName), + primaryButton: primaryButton, + secondaryButton: secondaryButton) case .mapError(.failedLoadingMap): self.init(id: error, title: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName), diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift index 0a839a116..be28ea918 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift @@ -8,32 +8,41 @@ import Combine import Foundation +import UIKit typealias LocationSharingScreenViewModelType = StateStoreViewModelV2 class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, LocationSharingScreenViewModelProtocol { private let roomProxy: JoinedRoomProxyProtocol private let timelineController: TimelineControllerProtocol + private let liveLocationManager: LiveLocationManagerProtocol private let analytics: AnalyticsService private let userIndicatorController: UserIndicatorControllerProtocol + private let notificationCenter: NotificationCenter private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } + private var authorizationStatusSubscription: AnyCancellable? + init(interactionMode: LocationSharingInteractionMode, mapURLBuilder: MapTilerURLBuilderProtocol, liveLocationSharingEnabled: Bool, roomProxy: JoinedRoomProxyProtocol, timelineController: TimelineControllerProtocol, + liveLocationManager: LiveLocationManagerProtocol, analytics: AnalyticsService, userIndicatorController: UserIndicatorControllerProtocol, - mediaProvider: MediaProviderProtocol) { + mediaProvider: MediaProviderProtocol, + notificationCenter: NotificationCenter = .default) { self.roomProxy = roomProxy self.timelineController = timelineController + self.liveLocationManager = liveLocationManager self.analytics = analytics self.userIndicatorController = userIndicatorController + self.notificationCenter = notificationCenter super.init(initialViewState: .init(interactionMode: interactionMode, mapURLBuilder: mapURLBuilder, @@ -49,6 +58,8 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati switch viewAction { case .close: actionsSubject.send(.close) + case .startLiveLocation: + startLiveLocationSharing() case .selectLocation: guard let coordinate = state.bindings.mapCenterLocation else { return } let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil @@ -75,6 +86,13 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati self?.updateShownUserProfile(members: members) } .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]) { @@ -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 { guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage, geoURI: geoURI, @@ -157,6 +218,7 @@ extension LocationSharingScreenViewModel { liveLocationSharingEnabled: liveLocationSharingEnabled, roomProxy: JoinedRoomProxyMock(.init()), timelineController: MockTimelineController(), + liveLocationManager: LiveLocationManagerMock(), analytics: ServiceLocator.shared.analytics, userIndicatorController: UserIndicatorControllerMock(), mediaProvider: MediaProviderMock(configuration: .init())) diff --git a/ElementX/Sources/Screens/LocationSharing/View/LocationPickerSheet.swift b/ElementX/Sources/Screens/LocationSharing/View/LocationPickerSheet.swift index 721aa0bd3..1e122c18a 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/LocationPickerSheet.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/LocationPickerSheet.swift @@ -40,7 +40,9 @@ struct LocationPickerSheet: View { } } if context.viewState.showLiveLocationSharingButton { - Button { } label: { + Button { + context.send(viewAction: .startLiveLocation) + } label: { LocationPickerLabel(text: L10n.actionShareLiveLocation, icon: \.locationPinSolid, iconColor: .compound.iconAccentPrimary) diff --git a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift index f4bbf59d6..a26ef3ebd 100644 --- a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift +++ b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift @@ -25,6 +25,7 @@ struct LocationSharingScreenViewModelTests { liveLocationSharingEnabled: true, roomProxy: JoinedRoomProxyMock(.init()), timelineController: MockTimelineController(timelineProxy: timelineProxy), + liveLocationManager: LiveLocationManagerMock(), analytics: ServiceLocator.shared.analytics, userIndicatorController: UserIndicatorControllerMock(), mediaProvider: MediaProviderMock(configuration: .init()))