Show alert if user has not beacon and beaconInfo permissions in the room.

This commit is contained in:
Mauro Romito
2026-05-05 13:18:37 +02:00
committed by Mauro
parent 0acbc41432
commit 680a9a587c
6 changed files with 91 additions and 57 deletions
@@ -1408,6 +1408,7 @@
"screen_session_verification_waiting_to_accept_title" = "Waiting to accept request"; "screen_session_verification_waiting_to_accept_title" = "Waiting to accept request";
"screen_share_location_live_location_disclaimer_title" = "Your live location history will be stored in the room and visible to members after the session ends."; "screen_share_location_live_location_disclaimer_title" = "Your live location history will be stored in the room and visible to members after the session ends.";
"screen_share_location_live_location_duration_picker_title" = "Choose how long to share your live location."; "screen_share_location_live_location_duration_picker_title" = "Choose how long to share your live location.";
"screen_share_location_live_location_missing_permissions" = "You do not have permissions to share your live location in this room";
"screen_share_location_title" = "Share location"; "screen_share_location_title" = "Share location";
"screen_share_my_location_action" = "Share my location"; "screen_share_my_location_action" = "Share my location";
"screen_share_open_apple_maps" = "Open in Apple Maps"; "screen_share_open_apple_maps" = "Open in Apple Maps";
@@ -1408,6 +1408,7 @@
"screen_session_verification_waiting_to_accept_title" = "Waiting to accept request"; "screen_session_verification_waiting_to_accept_title" = "Waiting to accept request";
"screen_share_location_live_location_disclaimer_title" = "Your live location history will be stored in the room and visible to members after the session ends."; "screen_share_location_live_location_disclaimer_title" = "Your live location history will be stored in the room and visible to members after the session ends.";
"screen_share_location_live_location_duration_picker_title" = "Choose how long to share your live location."; "screen_share_location_live_location_duration_picker_title" = "Choose how long to share your live location.";
"screen_share_location_live_location_missing_permissions" = "You do not have permissions to share your live location in this room";
"screen_share_location_title" = "Share location"; "screen_share_location_title" = "Share location";
"screen_share_my_location_action" = "Share my location"; "screen_share_my_location_action" = "Share my location";
"screen_share_open_apple_maps" = "Open in Apple Maps"; "screen_share_open_apple_maps" = "Open in Apple Maps";
+2
View File
@@ -3265,6 +3265,8 @@ internal enum L10n {
internal static var screenShareLocationLiveLocationDisclaimerTitle: String { return L10n.tr("Localizable", "screen_share_location_live_location_disclaimer_title") } internal static var screenShareLocationLiveLocationDisclaimerTitle: String { return L10n.tr("Localizable", "screen_share_location_live_location_disclaimer_title") }
/// Choose how long to share your live location. /// Choose how long to share your live location.
internal static var screenShareLocationLiveLocationDurationPickerTitle: String { return L10n.tr("Localizable", "screen_share_location_live_location_duration_picker_title") } internal static var screenShareLocationLiveLocationDurationPickerTitle: String { return L10n.tr("Localizable", "screen_share_location_live_location_duration_picker_title") }
/// You do not have permissions to share your live location in this room
internal static var screenShareLocationLiveLocationMissingPermissions: String { return L10n.tr("Localizable", "screen_share_location_live_location_missing_permissions") }
/// Share location /// Share location
internal static var screenShareLocationTitle: String { return L10n.tr("Localizable", "screen_share_location_title") } internal static var screenShareLocationTitle: String { return L10n.tr("Localizable", "screen_share_location_title") }
/// Share my location /// Share my location
@@ -13,6 +13,7 @@ import MatrixRustSDK
enum LocationSharingViewAlert: Hashable { enum LocationSharingViewAlert: Hashable {
case missingAuthorization case missingAuthorization
case missingAlwaysAuthorization case missingAlwaysAuthorization
case missingLiveLocationSharingPermission
case liveLocationDisclaimer case liveLocationDisclaimer
case liveLocationDurationSelection case liveLocationDurationSelection
case mapError(MapLibreError) case mapError(MapLibreError)
@@ -213,6 +214,10 @@ extension AlertInfo where T == LocationSharingViewAlert {
message: L10n.dialogPermissionLiveLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName), message: L10n.dialogPermissionLiveLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton, primaryButton: primaryButton,
secondaryButton: secondaryButton) secondaryButton: secondaryButton)
case .missingLiveLocationSharingPermission:
self.init(id: alertID,
title: L10n.screenShareLocationLiveLocationMissingPermissions,
primaryButton: primaryButton)
case .liveLocationDisclaimer: case .liveLocationDisclaimer:
self.init(id: alertID, self.init(id: alertID,
title: L10n.screenShareLocationLiveLocationDisclaimerTitle, title: L10n.screenShareLocationLiveLocationDisclaimerTitle,
@@ -68,7 +68,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
case .close: case .close:
actionsSubject.send(.close) actionsSubject.send(.close)
case .startLiveLocation: case .startLiveLocation:
checkAlwaysShareLocationPermission() startLiveLocation()
case .selectLocation: case .selectLocation:
guard let coordinate = state.bindings.mapCenterLocation else { return } guard let coordinate = state.bindings.mapCenterLocation else { return }
let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil
@@ -171,13 +171,17 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
} }
private static let durationFormatter: DateComponentsFormatter = { private func startLiveLocation() {
let formatter = DateComponentsFormatter() guard let powerLevels = roomProxy.infoPublisher.value.powerLevels,
formatter.unitsStyle = .full powerLevels.canOwnUser(sendStateEvent: .beaconInfo),
formatter.allowedUnits = [.hour, .minute] powerLevels.canOwnUser(sendMessage: .beacon) else {
return formatter state.bindings.alertInfo = .init(alertID: .missingLiveLocationSharingPermission)
}() return
}
checkAlwaysShareLocationPermission()
}
private func checkAlwaysShareLocationPermission() { private func checkAlwaysShareLocationPermission() {
authorizationStatusSubscription = nil authorizationStatusSubscription = nil
let authorizationStatus = liveLocationManager.authorizationStatus.value let authorizationStatus = liveLocationManager.authorizationStatus.value
@@ -231,6 +235,15 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
} }
/// It's easier to achieve the format we want with a DateComponentsFormatter
/// than using the `.formatted` function of Duration.
private static let durationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.allowedUnits = [.hour, .minute]
return formatter
}()
private func showLiveLocationDurationPicker() { private func showLiveLocationDurationPicker() {
let durations: [Duration] = [.seconds(15 * 60), // 15 minutes let durations: [Duration] = [.seconds(15 * 60), // 15 minutes
.seconds(60 * 60), // 1 hour .seconds(60 * 60), // 1 hour
@@ -70,7 +70,7 @@ struct LocationSharingScreenViewModelTests {
let AuthorizationError = AlertInfo(alertID: .missingAuthorization) let AuthorizationError = AlertInfo(alertID: .missingAuthorization)
#expect(AuthorizationError.message == L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName)) #expect(AuthorizationError.message == L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName))
} }
@Test @Test
mutating func sendUserLocation() async throws { mutating func sendUserLocation() async throws {
setupViewModel() setupViewModel()
@@ -92,14 +92,14 @@ struct LocationSharingScreenViewModelTests {
try await deferred.fulfill() try await deferred.fulfill()
} }
} }
@Test @Test
mutating func sendPickedLocation() async throws { mutating func sendPickedLocation() async throws {
setupViewModel() setupViewModel()
context.mapCenterLocation = .init(latitude: 0, longitude: 0) context.mapCenterLocation = .init(latitude: 0, longitude: 0)
context.isLocationAuthorized = nil context.isLocationAuthorized = nil
context.geolocationUncertainty = 10 context.geolocationUncertainty = 10
let deferred = deferFulfillment(viewModel.actions) { $0 == .close } let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
try await confirmation { confirmation in try await confirmation { confirmation in
@@ -117,7 +117,7 @@ struct LocationSharingScreenViewModelTests {
} }
// MARK: - isLocationLoading Tests // MARK: - isLocationLoading Tests
@Test @Test
mutating func isLocationLoadingInPickerModeWithAuthorizationNotDetermined() { mutating func isLocationLoadingInPickerModeWithAuthorizationNotDetermined() {
setupViewModel() setupViewModel()
@@ -125,7 +125,7 @@ struct LocationSharingScreenViewModelTests {
context.hasLoadedUserLocation = false context.hasLoadedUserLocation = false
#expect(context.viewState.isLocationLoading) #expect(context.viewState.isLocationLoading)
} }
@Test @Test
mutating func isLocationLoadingInPickerModeWithAuthorizationGranted() { mutating func isLocationLoadingInPickerModeWithAuthorizationGranted() {
setupViewModel() setupViewModel()
@@ -133,7 +133,7 @@ struct LocationSharingScreenViewModelTests {
context.hasLoadedUserLocation = false context.hasLoadedUserLocation = false
#expect(context.viewState.isLocationLoading) #expect(context.viewState.isLocationLoading)
} }
@Test @Test
mutating func isLocationNotLoadingInPickerModeWhenLocationLoaded() { mutating func isLocationNotLoadingInPickerModeWhenLocationLoaded() {
setupViewModel() setupViewModel()
@@ -141,7 +141,7 @@ struct LocationSharingScreenViewModelTests {
context.hasLoadedUserLocation = true context.hasLoadedUserLocation = true
#expect(!context.viewState.isLocationLoading) #expect(!context.viewState.isLocationLoading)
} }
@Test @Test
mutating func isLocationNotLoadingInPickerModeWhenAuthorizationDenied() { mutating func isLocationNotLoadingInPickerModeWhenAuthorizationDenied() {
setupViewModel() setupViewModel()
@@ -149,7 +149,7 @@ struct LocationSharingScreenViewModelTests {
context.hasLoadedUserLocation = false context.hasLoadedUserLocation = false
#expect(!context.viewState.isLocationLoading) #expect(!context.viewState.isLocationLoading)
} }
@Test @Test
mutating func isLocationNotLoadingInNonPickerModeWithAuthorizationNotDetermined() { mutating func isLocationNotLoadingInNonPickerModeWithAuthorizationNotDetermined() {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org") let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org")
@@ -171,7 +171,17 @@ struct LocationSharingScreenViewModelTests {
context.hasLoadedUserLocation = false context.hasLoadedUserLocation = false
#expect(context.viewState.isLocationLoading) #expect(context.viewState.isLocationLoading)
} }
// MARK: - Live Location Permission Tests
@Test
mutating func startLiveLocationWithoutPermission() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways),
members: .allMembers)
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingLiveLocationSharingPermission)
}
// MARK: - Live Location Authorization Tests // MARK: - Live Location Authorization Tests
@Test @Test
@@ -233,14 +243,14 @@ struct LocationSharingScreenViewModelTests {
} }
// MARK: - Live Location Start Flow Tests // MARK: - Live Location Start Flow Tests
@Test @Test
mutating func startLiveLocationShowsDisclaimer() { mutating func startLiveLocationShowsDisclaimer() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways)) setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways))
context.send(viewAction: .startLiveLocation) context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .liveLocationDisclaimer) #expect(context.alertInfo?.id == .liveLocationDisclaimer)
} }
@Test @Test
mutating func startLiveLocationDisclaimerDeclineSkipsStart() { mutating func startLiveLocationDisclaimerDeclineSkipsStart() {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways)) let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
@@ -249,7 +259,7 @@ struct LocationSharingScreenViewModelTests {
context.alertInfo?.primaryButton.action?() context.alertInfo?.primaryButton.action?()
#expect(!liveLocationManagerMock.startLiveLocationRoomIDDurationCalled) #expect(!liveLocationManagerMock.startLiveLocationRoomIDDurationCalled)
} }
@Test @Test
mutating func startLiveLocationDisclaimerAcceptShowsDurationPicker() async throws { mutating func startLiveLocationDisclaimerAcceptShowsDurationPicker() async throws {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways)) setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways))
@@ -259,7 +269,7 @@ struct LocationSharingScreenViewModelTests {
context.alertInfo?.secondaryButton?.action?() context.alertInfo?.secondaryButton?.action?()
try await deferred.fulfill() try await deferred.fulfill()
} }
@Test @Test
mutating func startLiveLocationDurationPickerCancelSkipsStart() async throws { mutating func startLiveLocationDurationPickerCancelSkipsStart() async throws {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways)) let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
@@ -271,7 +281,7 @@ struct LocationSharingScreenViewModelTests {
context.alertInfo?.primaryButton.action?() context.alertInfo?.primaryButton.action?()
#expect(!liveLocationManagerMock.startLiveLocationRoomIDDurationCalled) #expect(!liveLocationManagerMock.startLiveLocationRoomIDDurationCalled)
} }
@Test @Test
mutating func startLiveLocationSuccess() async throws { mutating func startLiveLocationSuccess() async throws {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways)) let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
@@ -280,16 +290,16 @@ struct LocationSharingScreenViewModelTests {
let durationPicker = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection } let durationPicker = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection }
context.alertInfo?.secondaryButton?.action?() context.alertInfo?.secondaryButton?.action?()
try await durationPicker.fulfill() try await durationPicker.fulfill()
let deferred = deferFulfillment(viewModel.actions) { $0 == .close } let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
context.alertInfo?.verticalButtons?.first?.action?() context.alertInfo?.verticalButtons?.first?.action?()
try await deferred.fulfill() try await deferred.fulfill()
#expect(liveLocationManagerMock.startLiveLocationRoomIDDurationCalled) #expect(liveLocationManagerMock.startLiveLocationRoomIDDurationCalled)
let arguments = try #require(liveLocationManagerMock.startLiveLocationRoomIDDurationReceivedArguments) let arguments = try #require(liveLocationManagerMock.startLiveLocationRoomIDDurationReceivedArguments)
#expect(arguments.duration == .seconds(60 * 15)) #expect(arguments.duration == .seconds(60 * 15))
} }
@Test @Test
mutating func startLiveLocationFailureDoesNotClose() async throws { mutating func startLiveLocationFailureDoesNotClose() async throws {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways)) let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
@@ -299,22 +309,22 @@ struct LocationSharingScreenViewModelTests {
let durationPicker = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection } let durationPicker = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection }
context.alertInfo?.secondaryButton?.action?() context.alertInfo?.secondaryButton?.action?()
try await durationPicker.fulfill() try await durationPicker.fulfill()
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1)) { $0 == .close } let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1)) { $0 == .close }
context.alertInfo?.verticalButtons?.first?.action?() context.alertInfo?.verticalButtons?.first?.action?()
try await deferredFailure.fulfill() try await deferredFailure.fulfill()
} }
// MARK: - Live Location Share Update Tests // MARK: - Live Location Share Update Tests
@Test @Test
mutating func viewLiveInitialSenderShownCorrectly() { mutating func viewLiveInitialSenderShownCorrectly() {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1) let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1)
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice") let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare]) let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject) setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
// Initial state is synchronously set from the interaction mode before the async subscription runs. // Initial state is synchronously set from the interaction mode before the async subscription runs.
let annotations = context.viewState.annotations let annotations = context.viewState.annotations
#expect(annotations.count == 1) #expect(annotations.count == 1)
@@ -324,22 +334,22 @@ struct LocationSharingScreenViewModelTests {
#expect(annotation?.coordinate.longitude == -0.1) #expect(annotation?.coordinate.longitude == -0.1)
#expect(annotation?.kind == .liveUser(.init(userID: "@alice:matrix.org", displayName: "Alice"))) #expect(annotation?.kind == .liveUser(.init(userID: "@alice:matrix.org", displayName: "Alice")))
} }
@Test @Test
mutating func viewLiveReceivesAdditionalLocationUpdates() async throws { mutating func viewLiveReceivesAdditionalLocationUpdates() async throws {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1) let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1)
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice") let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare]) let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject) setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
let bobShare = makeLiveLocationShare(userID: "@bob:matrix.org", latitude: 48.8, longitude: 2.3) let bobShare = makeLiveLocationShare(userID: "@bob:matrix.org", latitude: 48.8, longitude: 2.3)
let charlieShare = makeLiveLocationShare(userID: "@charlie:matrix.org", latitude: 40.7, longitude: -74.0) let charlieShare = makeLiveLocationShare(userID: "@charlie:matrix.org", latitude: 40.7, longitude: -74.0)
let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { $0.count == 3 } let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { $0.count == 3 }
liveLocationsSubject.send([aliceShare, bobShare, charlieShare]) liveLocationsSubject.send([aliceShare, bobShare, charlieShare])
try await deferred.fulfill() try await deferred.fulfill()
let annotations = context.viewState.annotations let annotations = context.viewState.annotations
#expect(annotations.count == 3) #expect(annotations.count == 3)
let annotationIDs = Set(annotations.map(\.id)) let annotationIDs = Set(annotations.map(\.id))
@@ -348,42 +358,42 @@ struct LocationSharingScreenViewModelTests {
#expect(annotations.first { $0.id == "@bob:matrix.org" }?.coordinate.latitude == 48.8) #expect(annotations.first { $0.id == "@bob:matrix.org" }?.coordinate.latitude == 48.8)
#expect(annotations.first { $0.id == "@charlie:matrix.org" }?.coordinate.latitude == 40.7) #expect(annotations.first { $0.id == "@charlie:matrix.org" }?.coordinate.latitude == 40.7)
} }
@Test @Test
mutating func viewLiveProfilesResolvedFromRoomMembers() async throws { mutating func viewLiveProfilesResolvedFromRoomMembers() async throws {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1) let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1)
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice") let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare]) let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject) setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
let bobShare = makeLiveLocationShare(userID: "@bob:matrix.org", latitude: 48.8, longitude: 2.3) let bobShare = makeLiveLocationShare(userID: "@bob:matrix.org", latitude: 48.8, longitude: 2.3)
let charlieShare = makeLiveLocationShare(userID: "@charlie:matrix.org", latitude: 40.7, longitude: -74.0) let charlieShare = makeLiveLocationShare(userID: "@charlie:matrix.org", latitude: 40.7, longitude: -74.0)
let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { $0.count == 3 } let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { $0.count == 3 }
liveLocationsSubject.send([aliceShare, bobShare, charlieShare]) liveLocationsSubject.send([aliceShare, bobShare, charlieShare])
try await deferred.fulfill() try await deferred.fulfill()
// Annotation marker kinds should carry profiles resolved from room members. // Annotation marker kinds should carry profiles resolved from room members.
let annotations = context.viewState.annotations let annotations = context.viewState.annotations
#expect(annotations.first { $0.id == "@alice:matrix.org" }?.kind == .liveUser(.init(userID: "@alice:matrix.org", displayName: "Alice"))) #expect(annotations.first { $0.id == "@alice:matrix.org" }?.kind == .liveUser(.init(userID: "@alice:matrix.org", displayName: "Alice")))
#expect(annotations.first { $0.id == "@bob:matrix.org" }?.kind == .liveUser(.init(userID: "@bob:matrix.org", displayName: "Bob"))) #expect(annotations.first { $0.id == "@bob:matrix.org" }?.kind == .liveUser(.init(userID: "@bob:matrix.org", displayName: "Bob")))
#expect(annotations.first { $0.id == "@charlie:matrix.org" }?.kind == .liveUser(.init(userID: "@charlie:matrix.org", displayName: "Charlie"))) #expect(annotations.first { $0.id == "@charlie:matrix.org" }?.kind == .liveUser(.init(userID: "@charlie:matrix.org", displayName: "Charlie")))
} }
@Test @Test
mutating func viewLiveFromBannerAwaitsFirstShareThenCentersOnIt() async throws { mutating func viewLiveFromBannerAwaitsFirstShareThenCentersOnIt() async throws {
// Simulates opening from the banner: no sender info and no initial share are available yet. // Simulates opening from the banner: no sender info and no initial share are available yet.
// The VM should wait for the first live location update and then center on the first share, // The VM should wait for the first live location update and then center on the first share,
// which is assumed to belong to the own user. // which is assumed to belong to the own user.
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([]) let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([])
let liveLocationServiceMock = RoomLiveLocationServiceMock() let liveLocationServiceMock = RoomLiveLocationServiceMock()
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher() liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
let roomProxyMock = JoinedRoomProxyMock(.init(members: .allMembers)) let roomProxyMock = JoinedRoomProxyMock(.init(members: .allMembers))
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: nil, initialLiveLocationShare: nil), viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: nil, initialLiveLocationShare: nil),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
@@ -392,34 +402,35 @@ struct LocationSharingScreenViewModelTests {
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))
// Initially no annotations and no map center since sender and share are both nil. // Initially no annotations and no map center since sender and share are both nil.
#expect(context.viewState.annotations.isEmpty) #expect(context.viewState.annotations.isEmpty)
#expect(context.mapCenterLocation == nil) #expect(context.mapCenterLocation == nil)
// Once the first update arrives, the VM populates annotations and centers the map on the first share. // Once the first update arrives, the VM populates annotations and centers the map on the first share.
let ownUserShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 51.5, longitude: -0.1) let ownUserShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 51.5, longitude: -0.1)
let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { !$0.isEmpty } let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { !$0.isEmpty }
liveLocationsSubject.send([ownUserShare]) liveLocationsSubject.send([ownUserShare])
try await deferred.fulfill() try await deferred.fulfill()
#expect(context.viewState.annotations.count == 1) #expect(context.viewState.annotations.count == 1)
#expect(context.viewState.annotations.first?.id == RoomMemberProxyMock.mockMe.userID) #expect(context.viewState.annotations.first?.id == RoomMemberProxyMock.mockMe.userID)
#expect(context.viewState.annotations.first?.coordinate.latitude == 51.5) #expect(context.viewState.annotations.first?.coordinate.latitude == 51.5)
#expect(context.viewState.annotations.first?.coordinate.longitude == -0.1) #expect(context.viewState.annotations.first?.coordinate.longitude == -0.1)
// The map should have been centered on the first received share's coordinates. // The map should have been centered on the first received share's coordinates.
#expect(context.mapCenterLocation?.latitude == 51.5) #expect(context.mapCenterLocation?.latitude == 51.5)
#expect(context.mapCenterLocation?.longitude == -0.1) #expect(context.mapCenterLocation?.longitude == -0.1)
} }
// MARK: - Private // MARK: - Private
private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) { private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(),
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker, viewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: JoinedRoomProxyMock(.init()), roomProxy: JoinedRoomProxyMock(.init(members: members)),
timelineController: MockTimelineController(timelineProxy: timelineProxy), timelineController: MockTimelineController(timelineProxy: timelineProxy),
liveLocationManager: LiveLocationManagerMock(liveLocationManagerConfiguration), liveLocationManager: LiveLocationManagerMock(liveLocationManagerConfiguration),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
@@ -428,11 +439,12 @@ struct LocationSharingScreenViewModelTests {
viewModel.state.bindings.isLocationAuthorized = true viewModel.state.bindings.isLocationAuthorized = true
} }
private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock) { private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock,
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker, viewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: JoinedRoomProxyMock(.init()), roomProxy: JoinedRoomProxyMock(.init(members: members)),
timelineController: MockTimelineController(timelineProxy: timelineProxy), timelineController: MockTimelineController(timelineProxy: timelineProxy),
liveLocationManager: liveLocationManagerMock, liveLocationManager: liveLocationManagerMock,
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
@@ -440,17 +452,17 @@ struct LocationSharingScreenViewModelTests {
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))
viewModel.state.bindings.isLocationAuthorized = true viewModel.state.bindings.isLocationAuthorized = true
} }
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 liveLocationServiceMock = RoomLiveLocationServiceMock() let liveLocationServiceMock = RoomLiveLocationServiceMock()
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher() liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
let roomProxyMock = JoinedRoomProxyMock(.init(members: members)) let roomProxyMock = JoinedRoomProxyMock(.init(members: members))
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare), viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
@@ -460,7 +472,7 @@ struct LocationSharingScreenViewModelTests {
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))
} }
private func makeLiveLocationShare(userID: String, latitude: Double = 0.0, longitude: Double = 0.0) -> LiveLocationShare { private func makeLiveLocationShare(userID: String, latitude: Double = 0.0, longitude: Double = 0.0) -> LiveLocationShare {
LiveLocationShare(userID: userID, LiveLocationShare(userID: userID,
geoURI: .init(latitude: latitude, longitude: longitude), geoURI: .init(latitude: latitude, longitude: longitude),