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
This commit is contained in:
Mauro
2026-06-05 13:51:57 +02:00
committed by GitHub
parent 4fed491853
commit da5360c8e8
13 changed files with 372 additions and 64 deletions
@@ -111,6 +111,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
liveLocationManager: flowParameters.userSession.liveLocationManager, liveLocationManager: flowParameters.userSession.liveLocationManager,
appSettings: flowParameters.appSettings,
appMediator: flowParameters.appMediator, appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics, analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,
@@ -1185,6 +1185,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
liveLocationManager: flowParameters.userSession.liveLocationManager, liveLocationManager: flowParameters.userSession.liveLocationManager,
appSettings: flowParameters.appSettings,
appMediator: flowParameters.appMediator, appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics, analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,
@@ -7564,11 +7564,6 @@ class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendabl
} }
} }
class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable { class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable {
var hasDisplayedLiveLocationDisclaimer: Bool {
get { return underlyingHasDisplayedLiveLocationDisclaimer }
set(value) { underlyingHasDisplayedLiveLocationDisclaimer = value }
}
var underlyingHasDisplayedLiveLocationDisclaimer: Bool!
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> {
get { return underlyingAuthorizationStatus } get { return underlyingAuthorizationStatus }
set(value) { underlyingAuthorizationStatus = value } set(value) { underlyingAuthorizationStatus = value }
@@ -12,7 +12,6 @@ extension LiveLocationManagerMock {
struct Configuration { struct Configuration {
var authorizationStatus: CLAuthorizationStatus = .notDetermined var authorizationStatus: CLAuthorizationStatus = .notDetermined
var requestAlwaysAuthorizationIfPossibleReturnValue = true var requestAlwaysAuthorizationIfPossibleReturnValue = true
var hasDisplayedLiveLocationDisclaimer = false
} }
convenience init(_ configuration: Configuration) { convenience init(_ configuration: Configuration) {
@@ -23,7 +22,5 @@ extension LiveLocationManagerMock {
requestAlwaysAuthorizationIfPossibleReturnValue = configuration.requestAlwaysAuthorizationIfPossibleReturnValue requestAlwaysAuthorizationIfPossibleReturnValue = configuration.requestAlwaysAuthorizationIfPossibleReturnValue
startLiveLocationRoomIDDurationReturnValue = .success(()) startLiveLocationRoomIDDurationReturnValue = .success(())
hasDisplayedLiveLocationDisclaimer = configuration.hasDisplayedLiveLocationDisclaimer
} }
} }
@@ -74,7 +74,9 @@ struct MapLibreMapView: UIViewRepresentable {
} }
// If the center coordinate was updated externally (not by the map itself), move the map. // 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 { newCenter != context.coordinator.lastReportedCenter {
context.coordinator.lastReportedCenter = newCenter context.coordinator.lastReportedCenter = newCenter
mapView.setCenter(newCenter, animated: true) mapView.setCenter(newCenter, animated: true)
@@ -86,6 +88,8 @@ struct MapLibreMapView: UIViewRepresentable {
updateAnnotations(in: mapView) updateAnnotations(in: mapView)
showUserLocation(in: mapView) showUserLocation(in: mapView)
context.coordinator.updateMarkerFollowing(in: mapView, markerID: showsUserLocationMode.followedMarkerID)
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
@@ -162,7 +166,8 @@ struct MapLibreMapView: UIViewRepresentable {
case (.show, _): case (.show, _):
mapView.showsUserLocation = true mapView.showsUserLocation = true
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) 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.showsUserLocation = false
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) 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. /// so that `updateUIView` can tell apart external binding changes from internal ones.
var lastReportedCenter: CLLocationCoordinate2D? 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 // MARK: - Setup
init(_ mapLibreView: MapLibreMapView) { init(_ mapLibreView: MapLibreMapView) {
self.mapLibreView = mapLibreView 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 // MARK: - MLNMapViewDelegate
func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
@@ -231,6 +286,10 @@ extension MapLibreMapView {
} }
func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { 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 // Avoid `Publishing changes from within view update` warnings
DispatchQueue.main.async { [mapLibreView, weak self] in DispatchQueue.main.async { [mapLibreView, weak self] in
let center = mapView.centerCoordinate let center = mapView.centerCoordinate
@@ -240,22 +299,14 @@ extension MapLibreMapView {
} }
func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool { 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. // Send userDidPan only for gestures that actually change the map center. The reason
switch reason { // is an option set and gestures can come combined with other reasons, so check for
case .gesturePan, // containment instead of an exact match.
.gesturePinch, let centerChangingGestures: MLNCameraChangeReason = [.gesturePan, .gesturePinch, .gestureRotate]
.gestureRotate: if !reason.intersection(centerChangingGestures).isEmpty {
// Stop following immediately, the camera would fight the gesture otherwise.
stopFollowingMarker()
mapLibreView.userDidPan?() mapLibreView.userDidPan?()
case .gestureOneFingerZoom,
.gestureTilt,
.gestureZoomIn,
.gestureZoomOut,
.programmatic,
.resetNorth,
.transitionCancelled:
break
default:
break
} }
return true return true
} }
@@ -11,13 +11,22 @@ import Foundation
/** /**
Behavior mode of the current user's location, can be hidden, only shown and shown following the user 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 /// this mode will show the user pin in map
case show case show
/// this mode will show the user pin in map and track him, panning the map automatically /// this mode will show the user pin in map and track him, panning the map automatically
case showAndFollow case showAndFollow
/// this mode will not show the user pin in map /// this mode will not show the user pin in map
case hide 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 { enum MapLibreError: Error {
@@ -15,6 +15,7 @@ struct LocationSharingScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol let roomProxy: JoinedRoomProxyProtocol
let timelineController: TimelineControllerProtocol let timelineController: TimelineControllerProtocol
let liveLocationManager: LiveLocationManagerProtocol let liveLocationManager: LiveLocationManagerProtocol
let appSettings: AppSettings
let appMediator: AppMediatorProtocol let appMediator: AppMediatorProtocol
let analytics: AnalyticsServiceProtocol let analytics: AnalyticsServiceProtocol
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
@@ -44,6 +45,7 @@ final class LocationSharingScreenCoordinator: CoordinatorProtocol {
roomProxy: parameters.roomProxy, roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
liveLocationManager: parameters.liveLocationManager, liveLocationManager: parameters.liveLocationManager,
appSettings: parameters.appSettings,
analytics: parameters.analytics, analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController, userIndicatorController: parameters.userIndicatorController,
mediaProvider: parameters.mediaProvider) mediaProvider: parameters.mediaProvider)
@@ -80,6 +80,8 @@ struct LocationSharingScreenViewState: BindableState {
var userProfiles: [String: UserProfileProxy] var userProfiles: [String: UserProfileProxy]
var liveLocationShares: [LiveLocationShare] = [] var liveLocationShares: [LiveLocationShare] = []
var isStoppingLiveLocation = false var isStoppingLiveLocation = false
/// Whether this device has an active live location sharing session in the room.
var isSharingLiveLocationOnThisDevice = false
var annotations: [LocationAnnotation] { var annotations: [LocationAnnotation] {
switch interactionMode { switch interactionMode {
@@ -111,6 +113,17 @@ struct LocationSharingScreenViewState: BindableState {
userID == ownUserID 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) var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
/// Indicates whether the user is sharing his current location /// Indicates whether the user is sharing his current location
@@ -118,6 +131,14 @@ struct LocationSharingScreenViewState: BindableState {
bindings.isLocationAuthorized == true && bindings.showsUserLocationMode == .showAndFollow 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 { var initialMapCenter: CLLocationCoordinate2D {
switch interactionMode { switch interactionMode {
case .picker: case .picker:
@@ -141,12 +162,17 @@ struct LocationSharingScreenViewState: BindableState {
if case .picker = interactionMode { if case .picker = interactionMode {
// In picker mode permissions are requested immediately so returns true // 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 // if the user's location has not yet been determined while location permissions are given or not yet set
!bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false return !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
} }
// 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 { var zoomLevel: Double {
@@ -17,6 +17,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
private let roomProxy: JoinedRoomProxyProtocol private let roomProxy: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol private let timelineController: TimelineControllerProtocol
private let liveLocationManager: LiveLocationManagerProtocol private let liveLocationManager: LiveLocationManagerProtocol
private let appSettings: AppSettings
private let analytics: AnalyticsServiceProtocol private let analytics: AnalyticsServiceProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let notificationCenter: NotificationCenter private let notificationCenter: NotificationCenter
@@ -36,6 +37,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
roomProxy: JoinedRoomProxyProtocol, roomProxy: JoinedRoomProxyProtocol,
timelineController: TimelineControllerProtocol, timelineController: TimelineControllerProtocol,
liveLocationManager: LiveLocationManagerProtocol, liveLocationManager: LiveLocationManagerProtocol,
appSettings: AppSettings,
analytics: AnalyticsServiceProtocol, analytics: AnalyticsServiceProtocol,
userIndicatorController: UserIndicatorControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
@@ -43,6 +45,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
self.roomProxy = roomProxy self.roomProxy = roomProxy
self.timelineController = timelineController self.timelineController = timelineController
self.liveLocationManager = liveLocationManager self.liveLocationManager = liveLocationManager
self.appSettings = appSettings
self.analytics = analytics self.analytics = analytics
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.notificationCenter = notificationCenter self.notificationCenter = notificationCenter
@@ -74,11 +77,12 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil
Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) } Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) }
case .userDidPan: case .userDidPan:
state.bindings.showsUserLocationMode = .show state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hide : .show
case .centerToUser: case .centerToUser:
switch state.bindings.isLocationAuthorized { switch state.bindings.isLocationAuthorized {
case .some(true), .none: 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): case .some(false):
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) } let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
state.bindings.alertInfo = .init(alertID: .missingAuthorization, state.bindings.alertInfo = .init(alertID: .missingAuthorization,
@@ -88,7 +92,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
case .stopLiveLocation: case .stopLiveLocation:
stopLiveLocation() stopLiveLocation()
case .setMapCenter(let coordinate): case .setMapCenter(let coordinate):
state.bindings.showsUserLocationMode = .show state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hide : .show
state.bindings.mapCenterLocation = coordinate state.bindings.mapCenterLocation = coordinate
} }
} }
@@ -100,9 +104,33 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
if let index = state.liveLocationShares.firstIndex(where: { $0.userID == roomProxy.ownUserID }) { if let index = state.liveLocationShares.firstIndex(where: { $0.userID == roomProxy.ownUserID }) {
state.liveLocationShares.remove(at: index) state.liveLocationShares.remove(at: index)
} }
updateUserLocationDotVisibility()
Task { await liveLocationManager.stopLiveLocation(roomID: roomProxy.id) } 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 { private func setupLiveLocationSubscription() async {
let liveLocationService = await roomProxy.makeLiveLocationService() let liveLocationService = await roomProxy.makeLiveLocationService()
self.liveLocationService = liveLocationService self.liveLocationService = liveLocationService
@@ -123,6 +151,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
updateUserProfiles(members: roomProxy.membersPublisher.value) updateUserProfiles(members: roomProxy.membersPublisher.value)
updateUserLocationDotVisibility()
if needsCenteringOnFirstLiveLocationUpdate, if needsCenteringOnFirstLiveLocationUpdate,
let liveLocation = state.liveLocationShares.first, let liveLocation = state.liveLocationShares.first,
@@ -140,6 +169,16 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
.store(in: &cancellables) .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) notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
// Let's remove the subscription if the user backgrounds the app (maybe to change their location settings) // 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() { private func showLiveLocationFlow() {
if liveLocationManager.hasDisplayedLiveLocationDisclaimer { if appSettings.liveLocationDisclaimerDisplayed {
showLiveLocationDurationPicker() showLiveLocationDurationPicker()
} else { } else {
state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer, state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer,
primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil), primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionAccept) { [weak self] in secondaryButton: .init(title: L10n.actionAccept) { [weak self] in
guard let self else { return } guard let self else { return }
liveLocationManager.hasDisplayedLiveLocationDisclaimer = true appSettings.liveLocationDisclaimerDisplayed = true
// Delay so SwiftUI finishes dismissing the current alert // Delay so SwiftUI finishes dismissing the current alert
// before presenting the next one. // before presenting the next one.
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -389,11 +428,14 @@ extension LocationSharingScreenViewModel {
let roomProxy = JoinedRoomProxyMock(.init(members: .allMembers, ownUserID: RoomMemberProxyMock.mockMe.userID)) let roomProxy = JoinedRoomProxyMock(.init(members: .allMembers, ownUserID: RoomMemberProxyMock.mockMe.userID))
roomProxy.makeLiveLocationServiceReturnValue = liveLocationServiceMock roomProxy.makeLiveLocationServiceReturnValue = liveLocationServiceMock
let appSettings = AppSettings.volatile()
return LocationSharingScreenViewModel(interactionMode: interactionMode, return LocationSharingScreenViewModel(interactionMode: interactionMode,
mapURLBuilder: AppSettings.volatile().mapTilerSettings.publisher.value, mapURLBuilder: appSettings.mapTilerSettings.publisher.value,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: TimelineControllerMock(.init()), timelineController: TimelineControllerMock(.init()),
liveLocationManager: LiveLocationManagerMock(), liveLocationManager: LiveLocationManagerMock(),
appSettings: appSettings,
analytics: AnalyticsServiceMock(.init()), analytics: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(.init())) mediaProvider: MediaProviderMock(.init()))
@@ -97,7 +97,7 @@ struct LocationSharingScreen: View {
.tint(.compound.iconPrimary) .tint(.compound.iconPrimary)
.padding(13) .padding(13)
} else { } else {
CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator) CompoundIcon(context.viewState.isMapCenteredOnUser ? \.locationNavigatorCentred : \.locationNavigator)
.foregroundStyle(.compound.iconPrimary) .foregroundStyle(.compound.iconPrimary)
.padding(13) .padding(13)
} }
@@ -71,15 +71,6 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
// MARK: - LiveLocationManagerProtocol // MARK: - LiveLocationManagerProtocol
var hasDisplayedLiveLocationDisclaimer: Bool {
get {
appSettings.liveLocationDisclaimerDisplayed
}
set {
appSettings.liveLocationDisclaimerDisplayed = newValue
}
}
@discardableResult @discardableResult
func requestAlwaysAuthorizationIfPossible() -> Bool { func requestAlwaysAuthorizationIfPossible() -> Bool {
guard !appSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false } guard !appSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false }
@@ -16,8 +16,6 @@ enum LiveLocationManagerError: Error {
// sourcery: AutoMockable // sourcery: AutoMockable
protocol LiveLocationManagerProtocol: AnyObject { 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. /// Publishes the current location authorization status.
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { get } var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { get }
@@ -13,7 +13,10 @@ import Testing
@MainActor @MainActor
struct LocationSharingScreenViewModelTests { struct LocationSharingScreenViewModelTests {
private static let roomID = "!live-location-room:matrix.org"
private var timelineProxy: TimelineProxyMock! private var timelineProxy: TimelineProxyMock!
private var appSettings: AppSettings!
private var viewModel: LocationSharingScreenViewModel! private var viewModel: LocationSharingScreenViewModel!
private var context: LocationSharingScreenViewModel.Context { private var context: LocationSharingScreenViewModel.Context {
@@ -172,6 +175,28 @@ struct LocationSharingScreenViewModelTests {
#expect(context.viewState.isLocationLoading) #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 // MARK: - Live Location Permission Tests
@Test @Test
@@ -401,6 +426,7 @@ struct LocationSharingScreenViewModelTests {
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))), timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
liveLocationManager: LiveLocationManagerMock(.init()), liveLocationManager: LiveLocationManagerMock(.init()),
appSettings: appSettings,
analytics: AnalyticsServiceMock(.init()), analytics: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(.init())) mediaProvider: MediaProviderMock(.init()))
@@ -425,32 +451,195 @@ struct LocationSharingScreenViewModelTests {
#expect(context.mapCenterLocation?.longitude == -0.1) #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 // MARK: - Private
private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(), private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(),
members: [RoomMemberProxyMock] = .allMembersAsAdmin) { members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
let appSettings = AppSettings.volatile() setupViewModel(liveLocationManagerMock: LiveLocationManagerMock(liveLocationManagerConfiguration), members: members)
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
} }
private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock, private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock,
members: [RoomMemberProxyMock] = .allMembersAsAdmin) { members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
let appSettings = AppSettings.volatile() appSettings = AppSettings.volatile()
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true), viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true),
mapURLBuilder: appSettings.mapTilerSettings.publisher.value, mapURLBuilder: appSettings.mapTilerSettings.publisher.value,
roomProxy: JoinedRoomProxyMock(.init(members: members)), roomProxy: JoinedRoomProxyMock(.init(members: members)),
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
liveLocationManager: liveLocationManagerMock, liveLocationManager: liveLocationManagerMock,
appSettings: appSettings,
analytics: AnalyticsServiceMock(.init()), analytics: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(.init())) mediaProvider: MediaProviderMock(.init()))
@@ -460,12 +649,17 @@ struct LocationSharingScreenViewModelTests {
private mutating func setupViewModelForViewLive(sender: TimelineItemSender, private mutating func setupViewModelForViewLive(sender: TimelineItemSender,
initialShare: LiveLocationShare, initialShare: LiveLocationShare,
liveLocationsSubject: CurrentValueSubject<[LiveLocationShare], Never>, liveLocationsSubject: CurrentValueSubject<[LiveLocationShare], Never>,
members: [RoomMemberProxyMock] = .allMembers) { members: [RoomMemberProxyMock] = .allMembers,
let appSettings = AppSettings.volatile() isSharingLiveLocationFromThisDevice: Bool = false) {
appSettings = AppSettings.volatile()
if isSharingLiveLocationFromThisDevice {
appSettings.liveLocationSharingSessionsByRoomID[Self.roomID] = .init(eventID: "$event:matrix.org", expirationDate: .distantFuture)
}
let liveLocationServiceMock = RoomLiveLocationServiceMock() let liveLocationServiceMock = RoomLiveLocationServiceMock()
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher() liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
let roomProxyMock = JoinedRoomProxyMock(.init(members: members)) let roomProxyMock = JoinedRoomProxyMock(.init(id: Self.roomID, members: members))
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare), viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare),
@@ -473,6 +667,7 @@ struct LocationSharingScreenViewModelTests {
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))), timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
liveLocationManager: LiveLocationManagerMock(.init()), liveLocationManager: LiveLocationManagerMock(.init()),
appSettings: appSettings,
analytics: AnalyticsServiceMock(.init()), analytics: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(.init())) mediaProvider: MediaProviderMock(.init()))