diff --git a/Riot/Assets/en.lproj/InfoPlist.strings b/Riot/Assets/en.lproj/InfoPlist.strings index a9513a6ed..a77ac433c 100644 --- a/Riot/Assets/en.lproj/InfoPlist.strings +++ b/Riot/Assets/en.lproj/InfoPlist.strings @@ -22,3 +22,4 @@ "NSCalendarsUsageDescription" = "See your scheduled meetings in the app."; "NSFaceIDUsageDescription" = "Face ID is used to access your app."; "NSLocationWhenInUseUsageDescription" = "When you share your location to people, Element needs access to show them a map."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "When you share your location to people, Element needs access to show them a map."; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 345b83020..697d08884 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2137,6 +2137,12 @@ Tap the + to start adding people."; "location_sharing_settings_toggle_title" = "Enable location sharing"; +"location_sharing_allow_background_location_title" = "Allow access"; +"location_sharing_allow_background_location_message" = "If you’d like to share your Live location, Element needs location access when the app is in the background. +To enable access, tap Settings> Location and select Always"; +"location_sharing_allow_background_location_validate_action" = "Settings"; +"location_sharing_allow_background_location_cancel_action" = "Not now"; + // MARK: Live location sharing "location_sharing_live_share_title" = "Share live location"; diff --git a/Riot/Categories/UIApplication.swift b/Riot/Categories/UIApplication.swift index 11299853d..d2e27c476 100644 --- a/Riot/Categories/UIApplication.swift +++ b/Riot/Categories/UIApplication.swift @@ -37,4 +37,12 @@ extension UIApplication { return sendAction(#selector(resignFirstResponder), to: nil, from: nil, for: nil) } + /// Open system application settings + func vc_openSettings(completion: ((Bool) -> Void)? = nil) { + guard let applicationSettingsURL = URL(string: UIApplication.openSettingsURLString) else { + completion?(false) + return + } + UIApplication.shared.open(applicationSettingsURL, completionHandler: completion) + } } diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 6cf4c26c7..4130a3cc1 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2751,6 +2751,22 @@ public class VectorL10n: NSObject { public static func localContactsAccessNotGranted(_ p1: String) -> String { return VectorL10n.tr("Vector", "local_contacts_access_not_granted", p1) } + /// Not now + public static var locationSharingAllowBackgroundLocationCancelAction: String { + return VectorL10n.tr("Vector", "location_sharing_allow_background_location_cancel_action") + } + /// If you’d like to share your Live location, Element needs location access when the app is in the background.\nTo enable access, tap Settings> Location and select Always + public static var locationSharingAllowBackgroundLocationMessage: String { + return VectorL10n.tr("Vector", "location_sharing_allow_background_location_message") + } + /// Allow access + public static var locationSharingAllowBackgroundLocationTitle: String { + return VectorL10n.tr("Vector", "location_sharing_allow_background_location_title") + } + /// Settings + public static var locationSharingAllowBackgroundLocationValidateAction: String { + return VectorL10n.tr("Vector", "location_sharing_allow_background_location_validate_action") + } /// Close public static var locationSharingCloseAction: String { return VectorL10n.tr("Vector", "location_sharing_close_action") diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index e218a1a11..af141ad72 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -87,6 +87,9 @@ final class AppCoordinator: NSObject, AppCoordinatorType { // Setup navigation router store _ = NavigationRouterStore.shared + // Setup user location services + _ = UserLocationServiceProvider.shared + if BuildSettings.enableSideMenu { self.addSideMenu() } diff --git a/Riot/Modules/LocationSharing/LocationManager.swift b/Riot/Modules/LocationSharing/LocationManager.swift new file mode 100644 index 000000000..7f8053fa5 --- /dev/null +++ b/Riot/Modules/LocationSharing/LocationManager.swift @@ -0,0 +1,216 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import CoreLocation + +protocol LocationManagerDelegate: AnyObject { + func locationManager(_ manager: LocationManager, didUpdateLocation location: CLLocation) +} + +/// Location accuracy +enum LocationManagerAccuracy { + case full + case reduced +} + +/// LocationManager handles device geolocalization +class LocationManager: NSObject { + + // MARK: - Constants + + private enum Constants { + static let distanceFiler: CLLocationDistance = 200.0 + static let waitForAuthorizationStatusDelay: TimeInterval = 0.5 + } + + // MARK: - Properties + + // MARK: Private + + private let locationManager: CLLocationManager + private var authorizationHandler: LocationAuthorizationHandler? + + // MARK: Public + + class var isLocationEnabled: Bool { + return CLLocationManager.locationServicesEnabled() + } + + private(set) var accuracy: LocationManagerAccuracy + + var isUpdatingLocation = false + + var lastLocation: CLLocation? + + weak var delegate: LocationManagerDelegate? + + // MARK: - Setup + + init(accuracy: LocationManagerAccuracy, allowsBackgroundLocationUpdates: Bool) { + + self.accuracy = accuracy + + let locationManager = CLLocationManager() + locationManager.distanceFilter = Constants.distanceFiler + + let desiredLocationAccuracy: CLLocationAccuracy + + switch accuracy { + case .full: + desiredLocationAccuracy = kCLLocationAccuracyNearestTenMeters + case .reduced: + desiredLocationAccuracy = kCLLocationAccuracyHundredMeters + } + + locationManager.desiredAccuracy = desiredLocationAccuracy + locationManager.allowsBackgroundLocationUpdates = allowsBackgroundLocationUpdates + + self.locationManager = locationManager + + super.init() + } + + // MARK: - Public + + /// Start monitoring user location + func start() { + + self.locationManager.delegate = self + + switch accuracy { + case .full: + self.locationManager.startUpdatingLocation() + case .reduced: + // Only listen to significant changes + // roughly after 500 meters moves or every 5 minutes minimum + // as mentioned in the Apple documentation https://developer.apple.com/documentation/corelocation/cllocationmanager/1423531-startmonitoringsignificantlocati + self.locationManager.startMonitoringSignificantLocationChanges() + } + + self.isUpdatingLocation = true + } + + /// Stop monitoring user location + func stop() { + + switch accuracy { + case .full: + self.locationManager.startUpdatingLocation() + case .reduced: + self.locationManager.stopMonitoringSignificantLocationChanges() + } + + self.locationManager.delegate = nil + self.isUpdatingLocation = false + } + + /// Request location authorization + func requestAuthorization(_ handler: @escaping LocationAuthorizationHandler) { + + let status = self.locationManager.authorizationStatus + + switch status { + case .notDetermined, .authorizedWhenInUse: + // Try to resquest always authorization + self.tryToRequestAlwaysAuthorization(handler: handler) + default: + handler(self.locationAuthorizationStatus(from: status)) + } + } + + // MARK: - Private + + // Try to request always authorization and if `locationManagerDidChangeAuthorization` is not called within `Constants.waitForAuthorizationStatusDelay` call the input handler. + // NOTE: As pointed in the Apple doc: + // - Core Location limits calls to requestAlwaysAuthorization(). After your app calls this method, further calls have no effect. + // - If the user responded to requestWhenInUseAuthorization() with Allow Once, then Core Location ignores further calls to requestAlwaysAuthorization() due to the temporary authorization. + // See https://developer.apple.com/documentation/corelocation/cllocationmanager/1620551-requestalwaysauthorization?changes=_6_6 + private func tryToRequestAlwaysAuthorization(handler: @escaping LocationAuthorizationHandler) { + self.authorizationHandler = handler + self.locationManager.requestAlwaysAuthorization() + + Timer.scheduledTimer(withTimeInterval: Constants.waitForAuthorizationStatusDelay, repeats: false) { [weak self] _ in + guard let self = self else { + return + } + + self.authorizationRequestDidComplete(with: self.locationManager.authorizationStatus) + } + } + + private func locationAuthorizationStatus(from clLocationAuthorizationStatus: CLAuthorizationStatus) -> LocationAuthorizationStatus { + + let status: LocationAuthorizationStatus + + switch clLocationAuthorizationStatus { + case .notDetermined: + status = .unknown + case .restricted, .denied: + status = .denied + case .authorizedAlways: + status = .authorizedAlways + case .authorizedWhenInUse: + status = .authorizedInForeground + @unknown default: + status = .unknown + } + + return status + } + + private func authorizationRequestDidComplete(with status: CLAuthorizationStatus) { + guard let authorizationHandler = self.authorizationHandler else { + return + } + + authorizationHandler(self.locationAuthorizationStatus(from: status)) + self.authorizationHandler = nil + } +} + +// MARK: - CLLocationManagerDelegate +extension LocationManager: CLLocationManagerDelegate { + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + + let status = self.locationManager.authorizationStatus + self.authorizationRequestDidComplete(with: status) + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + + guard let lastLocation = locations.last else { + return + } + + self.lastLocation = lastLocation + + self.delegate?.locationManager(self, didUpdateLocation: lastLocation) + } + + func locationManagerDidResumeLocationUpdates(_ manager: CLLocationManager) { + MXLog.debug("[LocationManager] Did resume location updates") + } + + func locationManagerDidPauseLocationUpdates(_ manager: CLLocationManager) { + MXLog.debug("[LocationManager] Did pause location updates") + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + MXLog.error("[LocationManager] Did failed with error :\(error)") + } +} diff --git a/Riot/Modules/LocationSharing/MXSession+LocationSharing.swift b/Riot/Modules/LocationSharing/MXSession+LocationSharing.swift new file mode 100644 index 000000000..e792f12da --- /dev/null +++ b/Riot/Modules/LocationSharing/MXSession+LocationSharing.swift @@ -0,0 +1,30 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixSDK + +extension MXSession { + + /// Convenient getter to retrieve UserLocationService associated to session user id + var userLocationService: UserLocationServiceProtocol? { + guard let myUserId = self.myUserId else { + return nil + } + + return UserLocationServiceProvider.shared.locationService(for: myUserId) + } +} diff --git a/Riot/Modules/LocationSharing/UserLocationService.swift b/Riot/Modules/LocationSharing/UserLocationService.swift new file mode 100644 index 000000000..69e9b8a20 --- /dev/null +++ b/Riot/Modules/LocationSharing/UserLocationService.swift @@ -0,0 +1,258 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import CoreLocation +import MatrixSDK + +/// UserLocationService handles live location sharing for the current user +class UserLocationService: UserLocationServiceProtocol { + + // MARK: - Constants + + private enum Constants { + + /// Minimum delay in milliseconds to send consecutive location for a beacon info + static let beaconSendMinInterval: UInt64 = 5000 // 5s + + /// Delay to check for experied beacons + static let beaconExpiredVerificationInterval: TimeInterval = 5 // 5s + } + + // MARK: - Properties + + // MARK: Private + + private let locationManager: LocationManager + private let session: MXSession + + /// All active beacon info summaries that belongs to this device + /// Do not update location for beacon info started on another device + private var deviceBeaconInfoSummaries: [MXBeaconInfoSummaryProtocol] = [] + + private var beaconInfoSummaryListener: Any? + + private var expiredBeaconVerificationTimer: Timer? + + // MARK: Public + + // MARK: - Setup + + init(session: MXSession) { + self.locationManager = LocationManager(accuracy: .full, allowsBackgroundLocationUpdates: false) + self.session = session + } + + // MARK: - Public + + func requestAuthorization(_ handler: @escaping LocationAuthorizationHandler) { + + self.locationManager.requestAuthorization(handler) + } + + func start() { + self.startLocationTracking() + self.startListeningBeaconInfoSummaries() + self.startVerifyingExpiredBeaconInfoSummaries() + } + + func stop() { + self.stopLocationTracking() + self.stopListeningBeaconInfoSummaries() + self.stopVerifyingExpiredBeaconInfoSummaries() + } + + // MARK: - Private + + // MARK: Beacon info summary + + private func startVerifyingExpiredBeaconInfoSummaries() { + + let timer = Timer.scheduledTimer(withTimeInterval: Constants.beaconExpiredVerificationInterval, repeats: false) { [weak self] _ in + + self?.verifyExpiredBeaconInfoSummaries() + } + + self.expiredBeaconVerificationTimer = timer + } + + private func stopVerifyingExpiredBeaconInfoSummaries() { + self.expiredBeaconVerificationTimer?.invalidate() + self.expiredBeaconVerificationTimer = nil + } + + private func verifyExpiredBeaconInfoSummaries() { + + for beaconInfoSummary in deviceBeaconInfoSummaries where beaconInfoSummary.isActive == false && beaconInfoSummary.hasStopped == false { + + // TODO: Prevent to stop several times + // Wait for isStopping status + self.session.locationService.stopUserLocationSharing(withBeaconInfoEventId: beaconInfoSummary.id, roomId: beaconInfoSummary.roomId) { response in + + } + } + + // Remove non active beacon info summaries + self.deviceBeaconInfoSummaries = self.deviceBeaconInfoSummaries.filter({ beaconInfoSummary in + return beaconInfoSummary.isActive + }) + } + + private func startListeningBeaconInfoSummaries() { + let beaconInfoSummaryListener = self.session.aggregations.beaconAggregations.listenToBeaconInfoSummaryUpdate { roomId, beaconInfoSummary in + + if self.isDeviceBeaconInfoSummary(beaconInfoSummary) { + + let existingIndex = self.deviceBeaconInfoSummaries.firstIndex(where: { beaconInfoSum in + beaconInfoSum.id == beaconInfoSummary.id + }) + + if beaconInfoSummary.isActive { + + if let index = existingIndex { + self.deviceBeaconInfoSummaries[index] = beaconInfoSummary + } else { + self.deviceBeaconInfoSummaries.append(beaconInfoSummary) + + // Send location if possible to a new beacon info summary + self.didReceiveDeviceNewBeaconInfoSummary(beaconInfoSummary) + } + } else { + + if let index = existingIndex { + self.deviceBeaconInfoSummaries.remove(at: index) + } + } + + self.updateLocationTrackingIfNeeded() + } + } + + self.beaconInfoSummaryListener = beaconInfoSummaryListener + } + + private func stopListeningBeaconInfoSummaries() { + + if let listener = self.beaconInfoSummaryListener { + self.session.aggregations.beaconAggregations.removeListener(listener) + } + } + + + private func isDeviceBeaconInfoSummary(_ beaconInfoSummary: MXBeaconInfoSummaryProtocol) -> Bool { + return beaconInfoSummary.userId == self.session.myUserId && beaconInfoSummary.deviceId == self.session.myDeviceId + } + + + private func didReceiveDeviceNewBeaconInfoSummary(_ beaconInfoSummary: MXBeaconInfoSummaryProtocol) { + + guard let lastLocation = self.locationManager.lastLocation else { + return + } + + self.sendLocation(lastLocation, for: beaconInfoSummary) + } + + // MARK: Location sending + + private func sendLocation(_ location: CLLocation, for beaconInfoSummary: MXBeaconInfoSummaryProtocol) { + guard self.canSendBeaconRequest(for: beaconInfoSummary) else { + return + } + + var localEcho: MXEvent? + + self.session.locationService.sendLocation(withBeaconInfoEventId: beaconInfoSummary.id, + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + inRoomWithId: beaconInfoSummary.roomId, + localEcho: &localEcho) { response in + + switch response { + case .success: + break + case .failure(let error): + MXLog.error("Fail to send location with error \(error)") + } + } + } + + private func didReceiveLocation(_ location: CLLocation) { + + for deviceBaconInfoSummary in deviceBeaconInfoSummaries { + self.sendLocation(location, for: deviceBaconInfoSummary) + } + } + + private func canSendBeaconRequest(for beaconInfoSummary: MXBeaconInfoSummaryProtocol) -> Bool { + + // Check if location manager is started + guard self.locationManager.isUpdatingLocation else { + return false + } + + let canSendBeaconRequest: Bool + + if let lastBeaconTimestamp = beaconInfoSummary.lastBeacon?.timestamp { + + let currentTimestamp = Date().timeIntervalSince1970 * 1000 + + canSendBeaconRequest = UInt64(currentTimestamp) - lastBeaconTimestamp >= Constants.beaconSendMinInterval + } else { + // The beacon info summary have no last beacon, we can send a request immediatly + canSendBeaconRequest = true + } + + return canSendBeaconRequest + } + + // MARK: Device location + + private func startLocationTracking() { + self.locationManager.start() + self.locationManager.delegate = self + } + + private func stopLocationTracking() { + self.locationManager.stop() + self.locationManager.delegate = nil + } + + private func updateLocationTrackingIfNeeded() { + + if self.deviceBeaconInfoSummaries.isEmpty { + + // Stop location tracking if there is no active beacon info summaries + if self.locationManager.isUpdatingLocation { + self.locationManager.stop() + } + } else { + + // Start location tracking if there is beacon info summaries and location tracking is stopped + if self.locationManager.isUpdatingLocation == false { + self.locationManager.start() + } + } + } +} + +// MARK: - LocationManagerDelegate +extension UserLocationService: LocationManagerDelegate { + + func locationManager(_ manager: LocationManager, didUpdateLocation location: CLLocation) { + self.didReceiveLocation(location) + } +} diff --git a/Riot/Modules/LocationSharing/UserLocationServiceProtocol.swift b/Riot/Modules/LocationSharing/UserLocationServiceProtocol.swift new file mode 100644 index 000000000..27309a9e8 --- /dev/null +++ b/Riot/Modules/LocationSharing/UserLocationServiceProtocol.swift @@ -0,0 +1,30 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Describes service that monitor and share current user device location to rooms where user shared is location +protocol UserLocationServiceProtocol { + + /// Request location permissions that enables live location sharing + func requestAuthorization(_ handler: @escaping LocationAuthorizationHandler) + + /// Start monitoring user location and look to rooms where location should be sent + func start() + + /// Stop monitoring user location + func stop() +} diff --git a/Riot/Modules/LocationSharing/UserLocationServiceProvider.swift b/Riot/Modules/LocationSharing/UserLocationServiceProvider.swift new file mode 100644 index 000000000..007e745e2 --- /dev/null +++ b/Riot/Modules/LocationSharing/UserLocationServiceProvider.swift @@ -0,0 +1,140 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// UserLocationServiceProvider enables to automatically store UserLocationService per user id and retrieve existing UserLocationService. +/// Note: UserLocationService management is not set inside UserSessionsService at the moment to avoid to expose user location management to extension targets. +class UserLocationServiceProvider { + + // MARK: - Constants + + static let shared = UserLocationServiceProvider() + + // MARK: - Properties + + private var locationServices: [String: UserLocationServiceProtocol] = [:] + + // MARK: - Setup + + private init() { + guard BuildSettings.liveLocationSharingEnabled else { + return + } + + self.registerUserSessionsServiceNotifications() + } + + // MARK: - Public + + func locationService(for userId: String) -> UserLocationServiceProtocol? { + return self.locationServices[userId] + } + + // MARK: - Private + + // MARK: Store + + private func addLocationService(_ userLocationService: UserLocationServiceProtocol, for userId: String) { + self.locationServices[userId] = userLocationService + } + + private func removeLocationService(for userId: String) { + self.locationServices[userId] = nil + } + + // MARK: UserLocationService setup + + private func setupUserLocationService(for userSession: UserSession) { + + self.tearDownUserLocationService(for: userSession.userId) + + let userLocationService = UserLocationService(session: userSession.matrixSession) + + self.addLocationService(userLocationService, for: userSession.userId) + + userLocationService.start() + + MXLog.debug("Start monitoring user live location sharing") + } + + func setupUserLocationServiceIfNeeded(for userSession: UserSession) { + + // Be sure Matrix session has is store setup to access beacon info summaries + guard userSession.matrixSession.state.rawValue >= MXSessionState.storeDataReady.rawValue else { + return + } + + let locationService = self.locationService(for: userSession.userId) + + guard locationService == nil else { + return + } + + self.setupUserLocationService(for: userSession) + } + + private func tearDownUserLocationService(for userId: String) { + + guard let locationService = self.locationService(for: userId) else { + return + } + + locationService.stop() + + self.removeLocationService(for: userId) + + MXLog.debug("Stop monitoring user live location sharing") + } + + // MARK: UserSessions management + + private func registerUserSessionsServiceNotifications() { + + NotificationCenter.default.addObserver(self, selector: #selector(userSessionsServiceDidAddUserSession(_:)), name: UserSessionsService.didAddUserSession, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(userSessionsServiceDidUpdateUserSession(_:)), name: UserSessionsService.userSessionDidChange, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(userSessionsServiceDidRemoveUserSession(_:)), name: UserSessionsService.didRemoveUserSession, object: nil) + } + + @objc private func userSessionsServiceDidAddUserSession(_ notification: Notification) { + + guard let userInfo = notification.userInfo, let userSession = userInfo[UserSessionsService.NotificationUserInfoKey.userSession] as? UserSession else { + return + } + + self.setupUserLocationServiceIfNeeded(for: userSession) + } + + @objc private func userSessionsServiceDidUpdateUserSession(_ notification: Notification) { + + guard let userInfo = notification.userInfo, let userSession = userInfo[UserSessionsService.NotificationUserInfoKey.userSession] as? UserSession else { + return + } + + self.setupUserLocationServiceIfNeeded(for: userSession) + } + + @objc private func userSessionsServiceDidRemoveUserSession(_ notification: Notification) { + + guard let userInfo = notification.userInfo, let userId = userInfo[UserSessionsService.NotificationUserInfoKey.userId] as? String else { + return + } + + self.tearDownUserLocationService(for: userId) + } +} diff --git a/Riot/SupportingFiles/Info.plist b/Riot/SupportingFiles/Info.plist index e80dcf500..c72fd5609 100644 --- a/Riot/SupportingFiles/Info.plist +++ b/Riot/SupportingFiles/Info.plist @@ -67,6 +67,8 @@ Siri is used to perform calls even from the lock screen. NSLocationWhenInUseUsageDescription When you share your location to people, Element needs access to show them a map. + NSLocationAlwaysAndWhenInUseUsageDescription + When you share your location to people, Element needs access to show them a map. UIBackgroundModes audio diff --git a/RiotSwiftUI/Info.plist b/RiotSwiftUI/Info.plist index 859c68836..a74d21864 100644 --- a/RiotSwiftUI/Info.plist +++ b/RiotSwiftUI/Info.plist @@ -24,5 +24,7 @@ RiotSwiftUI NSLocationWhenInUseUsageDescription When you share your location to people, Element needs access to show them a map. + NSLocationAlwaysAndWhenInUseUsageDescription + When you share your location to people, Element needs access to show them a map. diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift index e89a6f4ed..1abb36aa8 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift @@ -77,9 +77,12 @@ final class LocationSharingCoordinator: Coordinator, Presentable { init(parameters: LocationSharingCoordinatorParameters) { self.parameters = parameters + + let locationSharingService = LocationSharingService(userLocationService: parameters.roomDataSource.mxSession.userLocationService) + let viewModel = LocationSharingViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL, avatarData: parameters.avatarData, - isLiveLocationSharingEnabled: BuildSettings.liveLocationSharingEnabled) + isLiveLocationSharingEnabled: BuildSettings.liveLocationSharingEnabled, service: locationSharingService) let view = LocationSharingView(context: viewModel.context) .addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager)) diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingScreenState.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingScreenState.swift index 9004c0d9b..36aaf6880 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingScreenState.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingScreenState.swift @@ -28,10 +28,12 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { + let locationSharingService = MockLocationSharingService() + let mapStyleURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx")! let viewModel = LocationSharingViewModel(mapStyleURL: mapStyleURL, avatarData: AvatarInput(mxContentUri: "", matrixItemId: "alice:matrix.org", displayName: "Alice"), - isLiveLocationSharingEnabled: true) + isLiveLocationSharingEnabled: true, service: locationSharingService) return ([viewModel], AnyView(LocationSharingView(context: viewModel.context) .addDependency(MockAvatarService.example))) diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift index e8429ec34..78a38040b 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift @@ -29,13 +29,18 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie // MARK: Private + private let locationSharingService: LocationSharingServiceProtocol + // MARK: Public var completion: ((LocationSharingViewModelResult) -> Void)? // MARK: - Setup - init(mapStyleURL: URL, avatarData: AvatarInputProtocol, isLiveLocationSharingEnabled: Bool = false) { + init(mapStyleURL: URL, avatarData: AvatarInputProtocol, isLiveLocationSharingEnabled: Bool = false, service: LocationSharingServiceProtocol) { + + self.locationSharingService = service + let viewState = LocationSharingViewState(mapStyleURL: mapStyleURL, userAvatarData: avatarData, annotations: [], @@ -75,7 +80,7 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie case .goToUserLocation: state.bindings.pinLocation = nil case .startLiveSharing: - state.bindings.showingTimerSelector = true + self.startLiveLocationSharing() case .shareLiveLocation(let timeout): state.bindings.showingTimerSelector = false completion?(.shareLiveLocation(timeout: timeout.rawValue)) @@ -124,12 +129,38 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName), primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, primaryButtonCompletion), secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, { - if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) { - UIApplication.shared.open(applicationSettingsURL) - } + UIApplication.shared.vc_openSettings() })) default: break } } + + private func startLiveLocationSharing() { + + self.locationSharingService.requestAuthorization { [weak self] authorizationStatus in + + guard let self = self else { + return + } + + switch authorizationStatus { + case .unknown, .denied: + // Show error alert + self.state.bindings.alertInfo = AlertInfo(id: .userLocatingError, + title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName), + primaryButton: (VectorL10n.ok, { UIApplication.shared.vc_openSettings() + })) + case .authorizedInForeground: + // When user only authorized location in foreground, advize to use background location + self.state.bindings.alertInfo = AlertInfo(id: .userLocatingError, + title: VectorL10n.locationSharingAllowBackgroundLocationTitle, + message: VectorL10n.locationSharingAllowBackgroundLocationMessage, + primaryButton: (VectorL10n.locationSharingAllowBackgroundLocationCancelAction, { [weak self] in self?.state.bindings.showingTimerSelector = true }), + secondaryButton: (VectorL10n.locationSharingAllowBackgroundLocationValidateAction, { UIApplication.shared.vc_openSettings() })) + case .authorizedAlways: + self.state.bindings.showingTimerSelector = true + } + } + } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Service/LocationAuthorizationStatus.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Service/LocationAuthorizationStatus.swift new file mode 100644 index 000000000..ea9b07eaa --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Service/LocationAuthorizationStatus.swift @@ -0,0 +1,33 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Location authorization status +enum LocationAuthorizationStatus { + + /// Location status unknown + case unknown + + /// Location access is denied + case denied + + /// Location only authorized in foreground + case authorizedInForeground + + /// Location only authorized in foreground and background + case authorizedAlways +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Service/LocationSharingServiceProtocol.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Service/LocationSharingServiceProtocol.swift new file mode 100644 index 000000000..64cfac575 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Service/LocationSharingServiceProtocol.swift @@ -0,0 +1,28 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import CoreLocation + +/// Location authorization request handler +typealias LocationAuthorizationHandler = (_ authorizationStatus: LocationAuthorizationStatus) -> Void + +protocol LocationSharingServiceProtocol { + + /// Request location authorization + func requestAuthorization(_ handler: @escaping LocationAuthorizationHandler) +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Service/MatrixSDK/LocationSharingService.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Service/MatrixSDK/LocationSharingService.swift new file mode 100644 index 000000000..6d40d2ed9 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Service/MatrixSDK/LocationSharingService.swift @@ -0,0 +1,47 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import CoreLocation +import MatrixSDK + +class LocationSharingService: LocationSharingServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let userLocationService: UserLocationServiceProtocol? + + // MARK: Public + + // MARK: - Setup + + init(userLocationService: UserLocationServiceProtocol?) { + self.userLocationService = userLocationService + } + + // MARK: - Public + + func requestAuthorization(_ handler: @escaping LocationAuthorizationHandler) { + guard let userLocationService = self.userLocationService else { + handler(LocationAuthorizationStatus.unknown) + return + } + + userLocationService.requestAuthorization(handler) + } +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Service/Mock/MockLocationSharingService.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Service/Mock/MockLocationSharingService.swift new file mode 100644 index 000000000..44eb703fb --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Service/Mock/MockLocationSharingService.swift @@ -0,0 +1,26 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import CoreLocation + +@available(iOS 14.0, *) +class MockLocationSharingService: LocationSharingServiceProtocol { + func requestAuthorization(_ handler: @escaping LocationAuthorizationHandler) { + handler(.authorizedAlways) + } +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift index cd871170d..e70a1a2a8 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift @@ -49,7 +49,7 @@ class LocationSharingViewModelTests: XCTestCase { XCTFail() case .cancel: expectation.fulfill() - case .shareLiveLocation(timeout: let timeout): + case .shareLiveLocation: XCTFail() } } @@ -94,7 +94,10 @@ class LocationSharingViewModelTests: XCTestCase { } private func buildViewModel() -> LocationSharingViewModel { - LocationSharingViewModel(mapStyleURL: URL(string: "http://empty.com")!, - avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "")) + + let service = MockLocationSharingService() + + return LocationSharingViewModel(mapStyleURL: URL(string: "http://empty.com")!, + avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""), service: service) } } diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index f2feb1940..2fc955cb9 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -53,6 +53,7 @@ targets: - path: ../Riot/Categories/UIColor.swift - path: ../Riot/Categories/UISearchBar.swift - path: ../Riot/Categories/UIView.swift + - path: ../Riot/Categories/UIApplication.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index b7a0f9d00..6bf4196a4 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -62,6 +62,7 @@ targets: - path: ../Riot/Categories/UIColor.swift - path: ../Riot/Categories/UISearchBar.swift - path: ../Riot/Categories/UIView.swift + - path: ../Riot/Categories/UIApplication.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings diff --git a/changelog.d/5722.wip b/changelog.d/5722.wip new file mode 100644 index 000000000..a019ef025 --- /dev/null +++ b/changelog.d/5722.wip @@ -0,0 +1 @@ +Location sharing: Support sending live device location.