From da5360c8e849ac0452fa306d11123c38b10ceb79 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:51:57 +0200 Subject: [PATCH] Hide user dot and track own user marker in Live Location mode. (#5707) * added a new tracking mode for following a marker and the logic for switching such tracking mode * implemented the follow marker tracking mode * improved and added new tests --- .../PinnedEventsTimelineFlowCoordinator.swift | 1 + .../RoomFlowCoordinator.swift | 1 + .../Mocks/Generated/GeneratedMocks.swift | 5 - .../Mocks/LiveLocationManagerMock.swift | 3 - .../Other/MapLibre/MapLibreMapView.swift | 85 +++++-- .../Other/MapLibre/MapLibreModels.swift | 11 +- .../LocationSharingScreenCoordinator.swift | 2 + .../LocationSharingScreenModels.swift | 36 ++- .../LocationSharingScreenViewModel.swift | 54 ++++- .../View/LocationSharingScreen.swift | 2 +- .../Location/LiveLocationManager.swift | 9 - .../LiveLocationManagerProtocol.swift | 2 - .../LocationSharingScreenViewModelTests.swift | 225 ++++++++++++++++-- 13 files changed, 372 insertions(+), 64 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index a144f6df4..d3d3ae3d8 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -111,6 +111,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { roomProxy: roomProxy, timelineController: timelineController, liveLocationManager: flowParameters.userSession.liveLocationManager, + appSettings: flowParameters.appSettings, appMediator: flowParameters.appMediator, analytics: flowParameters.analytics, userIndicatorController: flowParameters.userIndicatorController, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index f34c9af10..f7bba9cad 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1185,6 +1185,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { roomProxy: roomProxy, timelineController: timelineController, liveLocationManager: flowParameters.userSession.liveLocationManager, + appSettings: flowParameters.appSettings, appMediator: flowParameters.appMediator, analytics: flowParameters.analytics, userIndicatorController: flowParameters.userIndicatorController, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 8946ea097..c4d60eaee 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -7564,11 +7564,6 @@ class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendabl } } class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable { - var hasDisplayedLiveLocationDisclaimer: Bool { - get { return underlyingHasDisplayedLiveLocationDisclaimer } - set(value) { underlyingHasDisplayedLiveLocationDisclaimer = value } - } - var underlyingHasDisplayedLiveLocationDisclaimer: Bool! var authorizationStatus: CurrentValuePublisher { get { return underlyingAuthorizationStatus } set(value) { underlyingAuthorizationStatus = value } diff --git a/ElementX/Sources/Mocks/LiveLocationManagerMock.swift b/ElementX/Sources/Mocks/LiveLocationManagerMock.swift index f13b55c3b..30063d614 100644 --- a/ElementX/Sources/Mocks/LiveLocationManagerMock.swift +++ b/ElementX/Sources/Mocks/LiveLocationManagerMock.swift @@ -12,7 +12,6 @@ extension LiveLocationManagerMock { struct Configuration { var authorizationStatus: CLAuthorizationStatus = .notDetermined var requestAlwaysAuthorizationIfPossibleReturnValue = true - var hasDisplayedLiveLocationDisclaimer = false } convenience init(_ configuration: Configuration) { @@ -23,7 +22,5 @@ extension LiveLocationManagerMock { requestAlwaysAuthorizationIfPossibleReturnValue = configuration.requestAlwaysAuthorizationIfPossibleReturnValue startLiveLocationRoomIDDurationReturnValue = .success(()) - - hasDisplayedLiveLocationDisclaimer = configuration.hasDisplayedLiveLocationDisclaimer } } diff --git a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift index 8ee685804..e2dfefbe9 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift @@ -74,7 +74,9 @@ struct MapLibreMapView: UIViewRepresentable { } // If the center coordinate was updated externally (not by the map itself), move the map. - if let newCenter = mapCenterCoordinate, + // Not applied while following a marker, where the camera is driven by the marker's position. + if showsUserLocationMode.followedMarkerID == nil, + let newCenter = mapCenterCoordinate, newCenter != context.coordinator.lastReportedCenter { context.coordinator.lastReportedCenter = newCenter mapView.setCenter(newCenter, animated: true) @@ -86,6 +88,8 @@ struct MapLibreMapView: UIViewRepresentable { updateAnnotations(in: mapView) showUserLocation(in: mapView) + + context.coordinator.updateMarkerFollowing(in: mapView, markerID: showsUserLocationMode.followedMarkerID) } func makeCoordinator() -> Coordinator { @@ -162,7 +166,8 @@ struct MapLibreMapView: UIViewRepresentable { case (.show, _): mapView.showsUserLocation = true mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) - case (.hide, _): + case (.hide, _), (.hideAndFollowMarker, _): + // In hideAndFollowMarker mode the camera following is handled by the coordinator. mapView.showsUserLocation = false mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) } @@ -182,12 +187,62 @@ extension MapLibreMapView { /// so that `updateUIView` can tell apart external binding changes from internal ones. var lastReportedCenter: CLLocationCoordinate2D? + /// The annotation the camera is currently locked on while in the `hideAndFollowMarker` mode. + private weak var followedAnnotation: LocationAnnotation? + /// Observes the followed annotation's coordinate, updated every frame by the `CoordinateAnimator`. + private var followedAnnotationObservation: NSKeyValueObservation? + // MARK: - Setup init(_ mapLibreView: MapLibreMapView) { self.mapLibreView = mapLibreView } + // MARK: - Marker following + + /// Keeps the camera locked on the marker with the given id, or stops following when nil. + func updateMarkerFollowing(in mapView: MLNMapView, markerID: String?) { + guard let markerID else { + stopFollowingMarker() + return + } + + guard let annotation = (mapView.annotations ?? []) + .compactMap({ $0 as? LocationAnnotation }) + .first(where: { $0.id == markerID }) else { + // The marker isn't on the map (yet), resolve it again on the next update. + stopFollowingMarker() + return + } + + guard annotation !== followedAnnotation else { return } + startFollowingMarker(annotation, in: mapView) + } + + private func startFollowingMarker(_ annotation: LocationAnnotation, in mapView: MLNMapView) { + followedAnnotation = annotation + followedAnnotationObservation = nil + + // Animate to the marker first, attaching the per-frame tracking only on completion + // so that it doesn't cancel the transition. + mapView.setCenter(annotation.coordinate, + zoomLevel: mapView.zoomLevel, + direction: -1, // negative value keeps the current direction + animated: true) { [weak self, weak annotation, weak mapView] in + guard let self, let annotation, let mapView, annotation === followedAnnotation else { return } + + // Mirror every animated coordinate update to keep the camera as smooth as the marker. + followedAnnotationObservation = annotation.observe(\.coordinate) { [weak mapView] annotation, _ in + mapView?.setCenter(annotation.coordinate, animated: false) + } + } + } + + private func stopFollowingMarker() { + followedAnnotation = nil + followedAnnotationObservation = nil + } + // MARK: - MLNMapViewDelegate func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { @@ -231,6 +286,10 @@ extension MapLibreMapView { } func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { + // While following a marker, don't flood the center binding with per-frame camera updates. + // Leaving `lastReportedCenter` untouched avoids an external re-centering when following stops. + guard mapLibreView.showsUserLocationMode.followedMarkerID == nil else { return } + // Avoid `Publishing changes from within view update` warnings DispatchQueue.main.async { [mapLibreView, weak self] in let center = mapView.centerCoordinate @@ -240,22 +299,14 @@ extension MapLibreMapView { } func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool { - // we send the userDidPan event only for the reasons that actually will change the map center, and not zoom only / rotations only events. - switch reason { - case .gesturePan, - .gesturePinch, - .gestureRotate: + // Send userDidPan only for gestures that actually change the map center. The reason + // is an option set and gestures can come combined with other reasons, so check for + // containment instead of an exact match. + let centerChangingGestures: MLNCameraChangeReason = [.gesturePan, .gesturePinch, .gestureRotate] + if !reason.intersection(centerChangingGestures).isEmpty { + // Stop following immediately, the camera would fight the gesture otherwise. + stopFollowingMarker() mapLibreView.userDidPan?() - case .gestureOneFingerZoom, - .gestureTilt, - .gestureZoomIn, - .gestureZoomOut, - .programmatic, - .resetNorth, - .transitionCancelled: - break - default: - break } return true } diff --git a/ElementX/Sources/Other/MapLibre/MapLibreModels.swift b/ElementX/Sources/Other/MapLibre/MapLibreModels.swift index 681a22266..b7cd5afdb 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreModels.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreModels.swift @@ -11,13 +11,22 @@ import Foundation /** Behavior mode of the current user's location, can be hidden, only shown and shown following the user */ -enum ShowUserLocationMode { +enum ShowUserLocationMode: Equatable { /// this mode will show the user pin in map case show /// this mode will show the user pin in map and track him, panning the map automatically case showAndFollow /// this mode will not show the user pin in map case hide + /// this mode will not show the user pin in map and will follow the marker with the given id, + /// panning the map automatically. + case hideAndFollowMarker(id: String) + + /// The id of the marker the map should follow when in the `hideAndFollowMarker` mode, nil otherwise. + var followedMarkerID: String? { + guard case .hideAndFollowMarker(let id) = self else { return nil } + return id + } } enum MapLibreError: Error { diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift index 8beb985fb..0d9926a50 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenCoordinator.swift @@ -15,6 +15,7 @@ struct LocationSharingScreenCoordinatorParameters { let roomProxy: JoinedRoomProxyProtocol let timelineController: TimelineControllerProtocol let liveLocationManager: LiveLocationManagerProtocol + let appSettings: AppSettings let appMediator: AppMediatorProtocol let analytics: AnalyticsServiceProtocol let userIndicatorController: UserIndicatorControllerProtocol @@ -44,6 +45,7 @@ final class LocationSharingScreenCoordinator: CoordinatorProtocol { roomProxy: parameters.roomProxy, timelineController: parameters.timelineController, liveLocationManager: parameters.liveLocationManager, + appSettings: parameters.appSettings, 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 1eeb0a4c3..06be38710 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -80,6 +80,8 @@ struct LocationSharingScreenViewState: BindableState { var userProfiles: [String: UserProfileProxy] var liveLocationShares: [LiveLocationShare] = [] var isStoppingLiveLocation = false + /// Whether this device has an active live location sharing session in the room. + var isSharingLiveLocationOnThisDevice = false var annotations: [LocationAnnotation] { switch interactionMode { @@ -111,6 +113,17 @@ struct LocationSharingScreenViewState: BindableState { userID == ownUserID } + /// Whether the own user has an active live location share in the room, from any of their devices. + var isOwnUserSharingLiveLocation: Bool { + liveLocationShares.contains { isOwnUser($0.userID) } + } + + /// Whether the own user's live location share in the room originates from this very device, + /// requiring both the room's share and this device's sharing session to be active. + var isOwnUserSharingLiveLocationOnThisDevice: Bool { + isOwnUserSharingLiveLocation && isSharingLiveLocationOnThisDevice + } + var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide) /// Indicates whether the user is sharing his current location @@ -118,6 +131,14 @@ struct LocationSharingScreenViewState: BindableState { bindings.isLocationAuthorized == true && bindings.showsUserLocationMode == .showAndFollow } + /// Whether the map is centered on the user, following either their dot or their own live marker. + var isMapCenteredOnUser: Bool { + if case .hideAndFollowMarker = bindings.showsUserLocationMode { + return true + } + return isSharingUserLocation + } + var initialMapCenter: CLLocationCoordinate2D { switch interactionMode { case .picker: @@ -141,12 +162,17 @@ struct LocationSharingScreenViewState: BindableState { if case .picker = interactionMode { // In picker mode permissions are requested immediately so returns true // if the user's location has not yet been determined while location permissions are given or not yet set - !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false - } else { - // In other modes permissions are requested only if the center to user button is tapped - // So we only display the loader if the user's location has not yet been determined while location permissions are given. - !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized == true + return !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false } + + // The dot is hidden in favour of the own user's marker, there is no location to wait for. + if case .viewLive = interactionMode, isOwnUserSharingLiveLocationOnThisDevice { + return false + } + + // In other modes permissions are requested only if the center to user button is tapped + // So we only display the loader if the user's location has not yet been determined while location permissions are given. + return !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized == true } var zoomLevel: Double { diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift index 2d1fbc3e4..b1132d346 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift @@ -17,6 +17,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati private let roomProxy: JoinedRoomProxyProtocol private let timelineController: TimelineControllerProtocol private let liveLocationManager: LiveLocationManagerProtocol + private let appSettings: AppSettings private let analytics: AnalyticsServiceProtocol private let userIndicatorController: UserIndicatorControllerProtocol private let notificationCenter: NotificationCenter @@ -36,6 +37,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati roomProxy: JoinedRoomProxyProtocol, timelineController: TimelineControllerProtocol, liveLocationManager: LiveLocationManagerProtocol, + appSettings: AppSettings, analytics: AnalyticsServiceProtocol, userIndicatorController: UserIndicatorControllerProtocol, mediaProvider: MediaProviderProtocol, @@ -43,6 +45,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati self.roomProxy = roomProxy self.timelineController = timelineController self.liveLocationManager = liveLocationManager + self.appSettings = appSettings self.analytics = analytics self.userIndicatorController = userIndicatorController self.notificationCenter = notificationCenter @@ -74,11 +77,12 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) } case .userDidPan: - state.bindings.showsUserLocationMode = .show + state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hide : .show case .centerToUser: switch state.bindings.isLocationAuthorized { case .some(true), .none: - state.bindings.showsUserLocationMode = .showAndFollow + // While sharing from this device the dot stays hidden, the own user's marker gets followed instead. + state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hideAndFollowMarker(id: state.ownUserID) : .showAndFollow case .some(false): let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) } state.bindings.alertInfo = .init(alertID: .missingAuthorization, @@ -88,7 +92,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati case .stopLiveLocation: stopLiveLocation() case .setMapCenter(let coordinate): - state.bindings.showsUserLocationMode = .show + state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hide : .show state.bindings.mapCenterLocation = coordinate } } @@ -100,9 +104,33 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati if let index = state.liveLocationShares.firstIndex(where: { $0.userID == roomProxy.ownUserID }) { state.liveLocationShares.remove(at: index) } + updateUserLocationDotVisibility() Task { await liveLocationManager.stopLiveLocation(roomID: roomProxy.id) } } + /// Hides the user location dot while the own user is sharing their live location from this + /// device, since their marker already represents it, and restores it once the share has + /// ended or when it comes from another device of theirs. + private func updateUserLocationDotVisibility() { + if state.isOwnUserSharingLiveLocationOnThisDevice { + switch state.bindings.showsUserLocationMode { + case .show: + state.bindings.showsUserLocationMode = .hide + case .showAndFollow: + state.bindings.showsUserLocationMode = .hideAndFollowMarker(id: state.ownUserID) + case .hide, .hideAndFollowMarker: + break + } + } else { + switch state.bindings.showsUserLocationMode { + case .hide, .hideAndFollowMarker: + state.bindings.showsUserLocationMode = .show + case .show, .showAndFollow: + break + } + } + } + private func setupLiveLocationSubscription() async { let liveLocationService = await roomProxy.makeLiveLocationService() self.liveLocationService = liveLocationService @@ -123,6 +151,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati } updateUserProfiles(members: roomProxy.membersPublisher.value) + updateUserLocationDotVisibility() if needsCenteringOnFirstLiveLocationUpdate, let liveLocation = state.liveLocationShares.first, @@ -140,6 +169,16 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati } .store(in: &cancellables) + appSettings.$liveLocationSharingSessionsByRoomID + .map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil } + .removeDuplicates() + .sink { [weak self] isSharingLiveLocationOnThisDevice in + guard let self else { return } + state.isSharingLiveLocationOnThisDevice = isSharingLiveLocationOnThisDevice + updateUserLocationDotVisibility() + } + .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) @@ -218,14 +257,14 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati } private func showLiveLocationFlow() { - if liveLocationManager.hasDisplayedLiveLocationDisclaimer { + if appSettings.liveLocationDisclaimerDisplayed { showLiveLocationDurationPicker() } else { state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer, primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil), secondaryButton: .init(title: L10n.actionAccept) { [weak self] in guard let self else { return } - liveLocationManager.hasDisplayedLiveLocationDisclaimer = true + appSettings.liveLocationDisclaimerDisplayed = true // Delay so SwiftUI finishes dismissing the current alert // before presenting the next one. DispatchQueue.main.async { @@ -389,11 +428,14 @@ extension LocationSharingScreenViewModel { let roomProxy = JoinedRoomProxyMock(.init(members: .allMembers, ownUserID: RoomMemberProxyMock.mockMe.userID)) roomProxy.makeLiveLocationServiceReturnValue = liveLocationServiceMock + let appSettings = AppSettings.volatile() + return LocationSharingScreenViewModel(interactionMode: interactionMode, - mapURLBuilder: AppSettings.volatile().mapTilerSettings.publisher.value, + mapURLBuilder: appSettings.mapTilerSettings.publisher.value, roomProxy: roomProxy, timelineController: TimelineControllerMock(.init()), liveLocationManager: LiveLocationManagerMock(), + appSettings: appSettings, analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), mediaProvider: MediaProviderMock(.init())) diff --git a/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift b/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift index 0f34293b9..d1aedbe30 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift @@ -97,7 +97,7 @@ struct LocationSharingScreen: View { .tint(.compound.iconPrimary) .padding(13) } else { - CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator) + CompoundIcon(context.viewState.isMapCenteredOnUser ? \.locationNavigatorCentred : \.locationNavigator) .foregroundStyle(.compound.iconPrimary) .padding(13) } diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift index a394765df..c326ed03c 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManager.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -71,15 +71,6 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana // MARK: - LiveLocationManagerProtocol - var hasDisplayedLiveLocationDisclaimer: Bool { - get { - appSettings.liveLocationDisclaimerDisplayed - } - set { - appSettings.liveLocationDisclaimerDisplayed = newValue - } - } - @discardableResult func requestAlwaysAuthorizationIfPossible() -> Bool { guard !appSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false } diff --git a/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift b/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift index 9fbd04903..6cc5862ea 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManagerProtocol.swift @@ -16,8 +16,6 @@ enum LiveLocationManagerError: Error { // sourcery: AutoMockable protocol LiveLocationManagerProtocol: AnyObject { - /// True if the live location disclaimer has been displayed already, will only be displayed once. - var hasDisplayedLiveLocationDisclaimer: Bool { get set } /// Publishes the current location authorization status. var authorizationStatus: CurrentValuePublisher { get } diff --git a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift index 0c32482e1..d4109f148 100644 --- a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift +++ b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift @@ -13,7 +13,10 @@ import Testing @MainActor struct LocationSharingScreenViewModelTests { + private static let roomID = "!live-location-room:matrix.org" + private var timelineProxy: TimelineProxyMock! + private var appSettings: AppSettings! private var viewModel: LocationSharingScreenViewModel! private var context: LocationSharingScreenViewModel.Context { @@ -172,6 +175,28 @@ struct LocationSharingScreenViewModelTests { #expect(context.viewState.isLocationLoading) } + @Test + mutating func isLocationNotLoadingWhileOwnUserSharingFromThisDevice() { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + context.isLocationAuthorized = true + context.hasLoadedUserLocation = false + + // The dot is hidden, no loader should block the center to user button. + #expect(context.showsUserLocationMode == .hide) + #expect(!context.viewState.isLocationLoading) + + // Same while following the marker. + context.send(viewAction: .centerToUser) + #expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID)) + #expect(!context.viewState.isLocationLoading) + } + // MARK: - Live Location Permission Tests @Test @@ -401,6 +426,7 @@ struct LocationSharingScreenViewModelTests { roomProxy: roomProxyMock, timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))), liveLocationManager: LiveLocationManagerMock(.init()), + appSettings: appSettings, analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), mediaProvider: MediaProviderMock(.init())) @@ -425,32 +451,195 @@ struct LocationSharingScreenViewModelTests { #expect(context.mapCenterLocation?.longitude == -0.1) } + // MARK: - User Location Dot Visibility Tests + + @Test + mutating func viewLiveOwnUserSharingFromThisDeviceHidesUserLocationDot() { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + + #expect(context.viewState.isOwnUserSharingLiveLocation) + #expect(context.viewState.isOwnUserSharingLiveLocationOnThisDevice) + #expect(context.showsUserLocationMode == .hide) + } + + @Test + mutating func viewLiveOwnUserSharingFromAnotherDeviceKeepsUserLocationDot() { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + // The own user has an active share in the room but there is no sharing session on this device. + setupViewModelForViewLive(sender: sender, initialShare: ownShare, liveLocationsSubject: liveLocationsSubject) + + #expect(context.viewState.isOwnUserSharingLiveLocation) + #expect(!context.viewState.isOwnUserSharingLiveLocationOnThisDevice) + #expect(context.showsUserLocationMode == .show) + + // The center to user button keeps its standard behaviour too. + context.isLocationAuthorized = true + context.send(viewAction: .centerToUser) + #expect(context.showsUserLocationMode == .showAndFollow) + } + + @Test + mutating func viewLiveOtherUserSharingShowsUserLocationDot() { + let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org") + let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare]) + + setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject) + + #expect(!context.viewState.isOwnUserSharingLiveLocation) + #expect(context.showsUserLocationMode == .show) + } + + @Test + mutating func viewLiveOwnUserStartingToShareHidesUserLocationDot() async throws { + let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org") + let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: aliceShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + #expect(context.showsUserLocationMode == .show) + + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .hide } + liveLocationsSubject.send([aliceShare, ownShare]) + try await deferred.fulfill() + } + + @Test + mutating func viewLiveOwnUserShareEndingRestoresUserLocationDot() async throws { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + #expect(context.showsUserLocationMode == .hide) + + let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .show } + liveLocationsSubject.send([]) + try await deferred.fulfill() + } + + @Test + mutating func viewLiveAnotherDeviceTakingOverShareRestoresUserLocationDot() async throws { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + #expect(context.showsUserLocationMode == .hide) + + // Another device takes over: this device's session is removed and a new own share arrives. + appSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: Self.roomID) + let newOwnShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 48.8, longitude: 2.3) + + let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .show } + liveLocationsSubject.send([newOwnShare]) + try await deferred.fulfill() + } + + @Test + mutating func panningAndCenteringWhileOwnUserSharingTogglesHiddenModes() { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + context.isLocationAuthorized = true + #expect(context.showsUserLocationMode == .hide) + #expect(!context.viewState.isMapCenteredOnUser) + + // Centering follows the own user's marker instead of the hidden dot. + context.send(viewAction: .centerToUser) + #expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID)) + #expect(context.viewState.isMapCenteredOnUser) + + context.send(viewAction: .userDidPan) + #expect(context.showsUserLocationMode == .hide) + #expect(!context.viewState.isMapCenteredOnUser) + + context.send(viewAction: .centerToUser) + #expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID)) + #expect(context.viewState.isMapCenteredOnUser) + + context.send(viewAction: .setMapCenter(.init(latitude: 51.5, longitude: -0.1))) + #expect(context.showsUserLocationMode == .hide) + } + + @Test + mutating func stopLiveLocationRestoresUserLocationDot() { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + #expect(context.showsUserLocationMode == .hide) + + context.send(viewAction: .stopLiveLocation) + #expect(context.showsUserLocationMode == .show) + } + + @Test + mutating func stopLiveLocationWhileFollowingMarkerRestoresUserLocationDot() { + let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID) + let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me") + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare]) + + setupViewModelForViewLive(sender: sender, + initialShare: ownShare, + liveLocationsSubject: liveLocationsSubject, + isSharingLiveLocationFromThisDevice: true) + context.isLocationAuthorized = true + + context.send(viewAction: .centerToUser) + #expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID)) + + // Stopping the share restores the dot without following it. + context.send(viewAction: .stopLiveLocation) + #expect(context.showsUserLocationMode == .show) + } + // MARK: - Private private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(), members: [RoomMemberProxyMock] = .allMembersAsAdmin) { - let appSettings = AppSettings.volatile() - timelineProxy = TimelineProxyMock(.init()) - viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true), - mapURLBuilder: appSettings.mapTilerSettings.publisher.value, - roomProxy: JoinedRoomProxyMock(.init(members: members)), - timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), - liveLocationManager: LiveLocationManagerMock(liveLocationManagerConfiguration), - analytics: AnalyticsServiceMock(.init()), - userIndicatorController: UserIndicatorControllerMock(), - mediaProvider: MediaProviderMock(.init())) - viewModel.state.bindings.isLocationAuthorized = true + setupViewModel(liveLocationManagerMock: LiveLocationManagerMock(liveLocationManagerConfiguration), members: members) } private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock, members: [RoomMemberProxyMock] = .allMembersAsAdmin) { - let appSettings = AppSettings.volatile() + appSettings = AppSettings.volatile() timelineProxy = TimelineProxyMock(.init()) viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true), mapURLBuilder: appSettings.mapTilerSettings.publisher.value, roomProxy: JoinedRoomProxyMock(.init(members: members)), timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), liveLocationManager: liveLocationManagerMock, + appSettings: appSettings, analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), mediaProvider: MediaProviderMock(.init())) @@ -460,12 +649,17 @@ struct LocationSharingScreenViewModelTests { private mutating func setupViewModelForViewLive(sender: TimelineItemSender, initialShare: LiveLocationShare, liveLocationsSubject: CurrentValueSubject<[LiveLocationShare], Never>, - members: [RoomMemberProxyMock] = .allMembers) { - let appSettings = AppSettings.volatile() + members: [RoomMemberProxyMock] = .allMembers, + isSharingLiveLocationFromThisDevice: Bool = false) { + appSettings = AppSettings.volatile() + if isSharingLiveLocationFromThisDevice { + appSettings.liveLocationSharingSessionsByRoomID[Self.roomID] = .init(eventID: "$event:matrix.org", expirationDate: .distantFuture) + } + let liveLocationServiceMock = RoomLiveLocationServiceMock() liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher() - let roomProxyMock = JoinedRoomProxyMock(.init(members: members)) + let roomProxyMock = JoinedRoomProxyMock(.init(id: Self.roomID, members: members)) roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare), @@ -473,6 +667,7 @@ struct LocationSharingScreenViewModelTests { roomProxy: roomProxyMock, timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))), liveLocationManager: LiveLocationManagerMock(.init()), + appSettings: appSettings, analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), mediaProvider: MediaProviderMock(.init()))