diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 76e240bd5f..947e72cb4e 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -605,6 +605,7 @@ 8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9FCE4D1E3A81AC1CC5CB91 /* AppLockSetupSettingsScreenCoordinator.swift */; }; 8DDC6F28C797D8685F2F8E32 /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B6B383F1FD04CC0E7B60C6 /* AnalyticsConsentState.swift */; }; 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */; }; + 8E7A902CA16E24928F83646C /* ElementCallServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */; }; 8ED8AF57A06F5EE9978ED23F /* AuthenticationStartScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FB89DC7F9A4A91020037001 /* AuthenticationStartScreenViewModelTests.swift */; }; 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */; }; 8F2FAA98457750D9D664136F /* Mapbox in Frameworks */ = {isa = PBXBuildFile; productRef = C1BF15833233CD3BDB7E2B1D /* Mapbox */; }; @@ -2052,6 +2053,7 @@ E2DCA495ED42D2463DDAA94D /* TimelineBubbleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBubbleLayout.swift; sourceTree = ""; }; E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyMock.swift; sourceTree = ""; }; E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModel.swift; sourceTree = ""; }; + E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceMock.swift; sourceTree = ""; }; E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; E4103AB4340F2974D690A12A /* CallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreen.swift; sourceTree = ""; }; @@ -2691,6 +2693,7 @@ 3BAC027034248429A438886B /* AppMediatorMock.swift */, E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */, 4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */, + E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */, 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, B2AD8A56CD37E23071A2F4BF /* PHGPostHogMock.swift */, D38391154120264910D19528 /* PollMock.swift */, @@ -6041,6 +6044,7 @@ AE1160076F663BF14E0E893A /* EffectsView.swift in Sources */, FE4593FC2A02AAF92E089565 /* ElementAnimations.swift in Sources */, 5732395A4F71F51F9C754C5A /* ElementCallService.swift in Sources */, + 8E7A902CA16E24928F83646C /* ElementCallServiceMock.swift in Sources */, 48416BBEB8DDF3E4DED0EDB6 /* ElementCallServiceProtocol.swift in Sources */, 07CC13C5729C24255348CBBD /* ElementCallWidgetDriver.swift in Sources */, 370AF5BFCD4384DD455479B6 /* ElementCallWidgetDriverProtocol.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 1d56181c26..f70687641b 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -149,10 +149,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg .receive(on: DispatchQueue.main) .sink { [weak self] action in switch action { - case .answerCall(let roomID): + case .startCall(let roomID): self?.handleAppRoute(.call(roomID: roomID)) - case .declineCall: - break + case .endCall: + break // Handled internally in the UserSessionFlowCoordinator } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 8fceeae02b..04bbceb546 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -168,6 +168,18 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } .store(in: &cancellables) + + elementCallService.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + switch action { + case .startCall: + break + case .endCall: + self?.dismissCallScreenIfNeeded() + } + } + .store(in: &cancellables) } func start() { @@ -569,6 +581,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentCallScreen(roomProxy: roomProxy) } + private func dismissCallScreenIfNeeded() { + guard navigationSplitCoordinator.sheetCoordinator is CallScreenCoordinator else { + return + } + + navigationSplitCoordinator.setSheetCoordinator(nil) + } + // MARK: Secure backup confirmation private func presentSecureBackupLogoutConfirmationScreen() { diff --git a/ElementX/Sources/Mocks/ElementCallServiceMock.swift b/ElementX/Sources/Mocks/ElementCallServiceMock.swift new file mode 100644 index 0000000000..27b2e81255 --- /dev/null +++ b/ElementX/Sources/Mocks/ElementCallServiceMock.swift @@ -0,0 +1,28 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +struct ElementCallServiceMockConfiguration { } + +extension ElementCallServiceMock { + convenience init(_ configuration: ElementCallServiceMockConfiguration) { + self.init() + + underlyingActions = PassthroughSubject().eraseToAnyPublisher() + } +} diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index e99c9bca98..b9be784f01 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4697,15 +4697,15 @@ class ElementCallServiceMock: ElementCallServiceProtocol { //MARK: - setupCallSession - var setupCallSessionTitleUnderlyingCallsCount = 0 - var setupCallSessionTitleCallsCount: Int { + var setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = 0 + var setupCallSessionRoomIDRoomDisplayNameCallsCount: Int { get { if Thread.isMainThread { - return setupCallSessionTitleUnderlyingCallsCount + return setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = setupCallSessionTitleUnderlyingCallsCount + returnValue = setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount } return returnValue! @@ -4713,28 +4713,28 @@ class ElementCallServiceMock: ElementCallServiceProtocol { } set { if Thread.isMainThread { - setupCallSessionTitleUnderlyingCallsCount = newValue + setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - setupCallSessionTitleUnderlyingCallsCount = newValue + setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = newValue } } } } - var setupCallSessionTitleCalled: Bool { - return setupCallSessionTitleCallsCount > 0 + var setupCallSessionRoomIDRoomDisplayNameCalled: Bool { + return setupCallSessionRoomIDRoomDisplayNameCallsCount > 0 } - var setupCallSessionTitleReceivedTitle: String? - var setupCallSessionTitleReceivedInvocations: [String] = [] - var setupCallSessionTitleClosure: ((String) async -> Void)? + var setupCallSessionRoomIDRoomDisplayNameReceivedArguments: (roomID: String, roomDisplayName: String)? + var setupCallSessionRoomIDRoomDisplayNameReceivedInvocations: [(roomID: String, roomDisplayName: String)] = [] + var setupCallSessionRoomIDRoomDisplayNameClosure: ((String, String) async -> Void)? - func setupCallSession(title: String) async { - setupCallSessionTitleCallsCount += 1 - setupCallSessionTitleReceivedTitle = title + func setupCallSession(roomID: String, roomDisplayName: String) async { + setupCallSessionRoomIDRoomDisplayNameCallsCount += 1 + setupCallSessionRoomIDRoomDisplayNameReceivedArguments = (roomID: roomID, roomDisplayName: roomDisplayName) DispatchQueue.main.async { - self.setupCallSessionTitleReceivedInvocations.append(title) + self.setupCallSessionRoomIDRoomDisplayNameReceivedInvocations.append((roomID: roomID, roomDisplayName: roomDisplayName)) } - await setupCallSessionTitleClosure?(title) + await setupCallSessionRoomIDRoomDisplayNameClosure?(roomID, roomDisplayName) } //MARK: - tearDownCallSession diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift index c46c08af54..a49f1f9e09 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -102,7 +102,7 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol return } - await elementCallService.setupCallSession(title: roomProxy.roomTitle) + await elementCallService.setupCallSession(roomID: roomProxy.id, roomDisplayName: roomProxy.roomTitle) let _ = await roomProxy.sendCallNotificationIfNeeeded() } @@ -128,15 +128,15 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol private func hangUp() async { let hangUpMessage = """ - "api":"toWidget", + {"api":"fromWidget", "widgetId":"\(widgetDriver.widgetID)", "requestId":"widgetapi-\(UUID())", "action":"im.vector.hangup", - "data":{} + "data":{}} """ let result = await widgetDriver.sendMessage(hangUpMessage) - MXLog.error("Result yo: \(result)") + MXLog.info("Sent hangUp message with result: \(result)") } private static let eventHandlerName = "elementx" diff --git a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift index 4ac47b027d..8b20c41138 100644 --- a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift +++ b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift @@ -181,7 +181,7 @@ struct CallScreen_Previews: PreviewProvider { roomProxy.elementCallWidgetDriverReturnValue = widgetDriver - return CallScreenViewModel(elementCallService: ElementCallServiceMock(), + return CallScreenViewModel(elementCallService: ElementCallServiceMock(.init()), roomProxy: roomProxy, callBaseURL: "https://call.element.io", clientID: "io.element.elementx") diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 19576cfde5..892fafa7e1 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -19,19 +19,36 @@ import CallKit import Combine import Foundation import PushKit +import UIKit class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate { - private let pushRegistry: PKPushRegistry + private struct CallID: Equatable { + let callKitID: UUID + let roomID: String + } + private let pushRegistry: PKPushRegistry private let callController = CXCallController() - - private var callProvider: CXProvider? - private var ongoingCallID: UUID? - - private var incomingCallRoomID: String? + private let callProvider: CXProvider = { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.includesCallsInRecents = true + + if let callKitIcon = UIImage(named: "images/app-logo") { + configuration.iconTemplateImageData = callKitIcon.pngData() + } + + // https://stackoverflow.com/a/46077628/730924 + configuration.supportedHandleTypes = [.generic] + + return CXProvider(configuration: configuration) + }() + private var incomingCallID: CallID? private var endUnansweredCallTask: Task? + private var ongoingCallID: CallID? + private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -44,47 +61,47 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe pushRegistry.delegate = self pushRegistry.desiredPushTypes = [.voIP] + + callProvider.setDelegate(self, queue: nil) } - func setupCallSession(title: String) async { - guard ongoingCallID == nil else { - return + func setupCallSession(roomID: String, roomDisplayName: String) async { + // Drop any ongoing calls when starting a new one + if ongoingCallID != nil { + tearDownCallSession() } - - let callID = UUID() + + // If this starting from a ring reuse those identifiers + // Make sure the roomID matches + let callID = if let incomingCallID, incomingCallID.roomID == roomID { + incomingCallID + } else { + CallID(callKitID: UUID(), roomID: roomID) + } + + incomingCallID = nil ongoingCallID = callID - let handle = CXHandle(type: .generic, value: title) - let startCallAction = CXStartCallAction(call: callID, handle: handle) + let handle = CXHandle(type: .generic, value: roomDisplayName) + let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle) startCallAction.isVideo = true - let transaction = CXTransaction(action: startCallAction) - do { - try await callController.request(transaction) + try await callController.request(CXTransaction(action: startCallAction)) + } catch { + MXLog.error("Failed requesting start call action with error: \(error)") + } + + do { // Setup the audio session even if setting up CallKit failed, ElementCall **is** running at this point try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: []) try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) } catch { - MXLog.error("Failed setting up VoIP session with error: \(error)") - tearDownCallSession() + MXLog.error("Failed setting up audio session with error: \(error)") } } func tearDownCallSession() { - guard let ongoingCallID else { - return - } - - try? AVAudioSession.sharedInstance().setActive(false) - - let endCallAction = CXEndCallAction(call: ongoingCallID) - let transaction = CXTransaction(action: endCallAction) - - callController.request(transaction) { error in - if let error { - MXLog.error("Failed transaction with error: \(error)") - } - } + tearDownCallSession(sendEndCallAction: true) } // MARK: - PKPushRegistryDelegate @@ -97,31 +114,18 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe return } - let callID = UUID() - ongoingCallID = callID + let callID = CallID(callKitID: UUID(), roomID: roomID) + incomingCallID = callID - incomingCallRoomID = roomID - - let configuration = CXProviderConfiguration() - configuration.supportsVideo = true - configuration.includesCallsInRecents = true - // Provide image icon if available - configuration.iconTemplateImageData = nil - - // https://stackoverflow.com/a/46077628/730924 - configuration.supportedHandleTypes = [.generic] + let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String let update = CXCallUpdate() update.hasVideo = true - - update.localizedCallerName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String - + update.localizedCallerName = roomDisplayName // https://stackoverflow.com/a/41230020/730924 update.remoteHandle = .init(type: .generic, value: roomID) - let callProvider = CXProvider(configuration: configuration) - callProvider.setDelegate(self, queue: nil) - callProvider.reportNewIncomingCall(with: callID, update: update) { error in + callProvider.reportNewIncomingCall(with: callID.callKitID, update: update) { error in if let error { MXLog.error("Failed reporting new incoming call with error: \(error)") } @@ -129,14 +133,15 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe completion() } - endUnansweredCallTask = Task { [weak self, callProvider, callID] in + endUnansweredCallTask = Task { [weak self] in try? await Task.sleep(for: .seconds(15)) - guard !Task.isCancelled else { + + guard let self, !Task.isCancelled else { return } - if let ongoingCallID = self?.ongoingCallID, ongoingCallID == callID { - callProvider.reportCall(with: callID, endedAt: .now, reason: .unanswered) + if let incomingCallID, incomingCallID.callKitID == callID.callKitID { + callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: .unanswered) } } } @@ -147,29 +152,57 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe MXLog.info("Call provider did reset: \(provider)") } + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + if let ongoingCallID { + provider.reportOutgoingCall(with: ongoingCallID.callKitID, connectedAt: nil) + } else { + MXLog.error("Failed starting call, missing ongoingCallID") + } + + action.fulfill() + } + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { - if let incomingCallRoomID { - Task { - // Dispatch to next run loop so it doesn't conflict with `setupCallSession` - actionsSubject.send(.answerCall(roomID: incomingCallRoomID)) - } - self.incomingCallRoomID = nil + if let incomingCallID { + actionsSubject.send(.startCall(roomID: incomingCallID.roomID)) endUnansweredCallTask?.cancel() } else { - MXLog.error("Failed answering incoming call, missing room ID") + MXLog.error("Failed answering incoming call, missing incomingCallID") } action.fulfill() } + func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + // Forward this to the widget somehow + // webView.evaluateJavaScript("groupCall.setLocalVideoMuted(!groupCall.isLocalVideoMuted())") + // webView.evaluateJavaScript("groupCall.setMicrophoneMuted(!groupCall.isMicrophoneMuted())" + } + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { - if let incomingCallRoomID { - actionsSubject.send(.declineCall(roomID: incomingCallRoomID)) - self.incomingCallRoomID = nil - } else { - MXLog.error("Failed declining incoming call, missing room ID") + if let ongoingCallID { + actionsSubject.send(.endCall(roomID: ongoingCallID.roomID)) } - + + tearDownCallSession(sendEndCallAction: false) + action.fulfill() } + + // MARK: - Private + + func tearDownCallSession(sendEndCallAction: Bool = true) { + try? AVAudioSession.sharedInstance().setActive(false) + + if sendEndCallAction, let ongoingCallID { + let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID)) + callController.request(transaction) { error in + if let error { + MXLog.error("Failed transaction with error: \(error)") + } + } + } + + ongoingCallID = nil + } } diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift index c05d593f17..06793f42ef 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -17,8 +17,8 @@ import Combine enum ElementCallServiceAction { - case answerCall(roomID: String) - case declineCall(roomID: String) + case startCall(roomID: String) + case endCall(roomID: String) } enum ElementCallServiceNotificationKey: String { @@ -32,7 +32,7 @@ let ElementCallServiceNotificationDiscardDelta = 10.0 protocol ElementCallServiceProtocol { var actions: AnyPublisher { get } - func setupCallSession(title: String) async + func setupCallSession(roomID: String, roomDisplayName: String) async func tearDownCallSession() } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 054a0ecd91..23239b1e98 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -525,7 +525,7 @@ class MockScreen: Identifiable { appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings), bugReportService: BugReportServiceMock(), - elementCallService: ElementCallServiceMock(), + elementCallService: ElementCallServiceMock(.init()), roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init()), appMediator: AppMediatorMock.default, appSettings: appSettings, @@ -641,7 +641,7 @@ class MockScreen: Identifiable { appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings), bugReportService: BugReportServiceMock(), - elementCallService: ElementCallServiceMock(), + elementCallService: ElementCallServiceMock(.init()), roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init(timelineController: timelineController)), appMediator: AppMediatorMock.default, appSettings: appSettings, diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index e4042728ea..0428dded55 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -46,7 +46,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase { navigationRootCoordinator: navigationRootCoordinator, appLockService: AppLockServiceMock(), bugReportService: BugReportServiceMock(), - elementCallService: ElementCallServiceMock(), + elementCallService: ElementCallServiceMock(.init()), roomTimelineControllerFactory: timelineControllerFactory, appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings,